diff options
Diffstat (limited to 'lib/private/Files')
127 files changed, 7456 insertions, 7590 deletions
diff --git a/lib/private/Files/AppData/AppData.php b/lib/private/Files/AppData/AppData.php index 237fcb42e03..c13372ae1d9 100644 --- a/lib/private/Files/AppData/AppData.php +++ b/lib/private/Files/AppData/AppData.php @@ -3,32 +3,14 @@ declare(strict_types=1); /** - * @copyright 2016 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\AppData; -use OCP\Cache\CappedMemoryCache; use OC\Files\SimpleFS\SimpleFolder; use OC\SystemConfig; +use OCP\Cache\CappedMemoryCache; use OCP\Files\Folder; use OCP\Files\IAppData; use OCP\Files\IRootFolder; @@ -53,8 +35,8 @@ class AppData implements IAppData { * @param string $appId */ public function __construct(IRootFolder $rootFolder, - SystemConfig $systemConfig, - string $appId) { + SystemConfig $systemConfig, + string $appId) { $this->rootFolder = $rootFolder; $this->config = $systemConfig; $this->appId = $appId; diff --git a/lib/private/Files/AppData/Factory.php b/lib/private/Files/AppData/Factory.php index 03f8fdedcbd..38b73f370b8 100644 --- a/lib/private/Files/AppData/Factory.php +++ b/lib/private/Files/AppData/Factory.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright 2016 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\AppData; @@ -39,7 +21,7 @@ class Factory implements IAppDataFactory { private array $folders = []; public function __construct(IRootFolder $rootFolder, - SystemConfig $systemConfig) { + SystemConfig $systemConfig) { $this->rootFolder = $rootFolder; $this->config = $systemConfig; } diff --git a/lib/private/Files/Cache/Cache.php b/lib/private/Files/Cache/Cache.php index 6440bf05a1d..329466e682d 100644 --- a/lib/private/Files/Cache/Cache.php +++ b/lib/private/Files/Cache/Cache.php @@ -1,54 +1,25 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Andreas Fischer <bantu@owncloud.com> - * @author Ari Selseng <ari@selseng.net> - * @author Artem Kochnev <MrJeos@gmail.com> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Florin Peter <github@florin-peter.de> - * @author Frédéric Fortier <frederic.fortier@oronospolytechnique.com> - * @author Jens-Christian Fischer <jens-christian.fischer@switch.ch> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Michael Gapczynski <GapczynskiM@gmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Files\Cache; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use OC\DB\Exceptions\DbalException; +use OC\DB\QueryBuilder\Sharded\ShardDefinition; use OC\Files\Search\SearchComparison; use OC\Files\Search\SearchQuery; +use OC\Files\Storage\Wrapper\Encryption; +use OC\SystemConfig; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Cache\CacheEntryInsertedEvent; +use OCP\Files\Cache\CacheEntryRemovedEvent; use OCP\Files\Cache\CacheEntryUpdatedEvent; use OCP\Files\Cache\CacheInsertEvent; -use OCP\Files\Cache\CacheEntryRemovedEvent; use OCP\Files\Cache\CacheUpdateEvent; use OCP\Files\Cache\ICache; use OCP\Files\Cache\ICacheEntry; @@ -58,7 +29,9 @@ use OCP\Files\Search\ISearchComparison; use OCP\Files\Search\ISearchOperator; use OCP\Files\Search\ISearchQuery; use OCP\Files\Storage\IStorage; +use OCP\FilesMetadata\IFilesMetadataManager; use OCP\IDBConnection; +use OCP\Util; use Psr\Log\LoggerInterface; /** @@ -79,61 +52,51 @@ class Cache implements ICache { /** * @var array partial data for the cache */ - protected $partial = []; - - /** - * @var string - */ - protected $storageId; - - private $storage; - - /** - * @var Storage $storageCache - */ - protected $storageCache; - - /** @var IMimeTypeLoader */ - protected $mimetypeLoader; - - /** - * @var IDBConnection - */ - protected $connection; - - /** - * @var IEventDispatcher - */ - protected $eventDispatcher; - - /** @var QuerySearchHelper */ - protected $querySearchHelper; - - /** - * @param IStorage $storage - */ - public function __construct(IStorage $storage) { + protected array $partial = []; + protected string $storageId; + protected Storage $storageCache; + protected IMimeTypeLoader $mimetypeLoader; + protected IDBConnection $connection; + protected SystemConfig $systemConfig; + protected LoggerInterface $logger; + protected QuerySearchHelper $querySearchHelper; + protected IEventDispatcher $eventDispatcher; + protected IFilesMetadataManager $metadataManager; + + public function __construct( + private IStorage $storage, + // this constructor is used in to many pleases to easily do proper di + // so instead we group it all together + ?CacheDependencies $dependencies = null, + ) { $this->storageId = $storage->getId(); - $this->storage = $storage; if (strlen($this->storageId) > 64) { $this->storageId = md5($this->storageId); } - - $this->storageCache = new Storage($storage); - $this->mimetypeLoader = \OC::$server->getMimeTypeLoader(); - $this->connection = \OC::$server->getDatabaseConnection(); - $this->eventDispatcher = \OC::$server->get(IEventDispatcher::class); - $this->querySearchHelper = \OC::$server->query(QuerySearchHelper::class); + if (!$dependencies) { + $dependencies = \OCP\Server::get(CacheDependencies::class); + } + $this->storageCache = new Storage($this->storage, true, $dependencies->getConnection()); + $this->mimetypeLoader = $dependencies->getMimeTypeLoader(); + $this->connection = $dependencies->getConnection(); + $this->systemConfig = $dependencies->getSystemConfig(); + $this->logger = $dependencies->getLogger(); + $this->querySearchHelper = $dependencies->getQuerySearchHelper(); + $this->eventDispatcher = $dependencies->getEventDispatcher(); + $this->metadataManager = $dependencies->getMetadataManager(); } protected function getQueryBuilder() { return new CacheQueryBuilder( - $this->connection, - \OC::$server->getSystemConfig(), - \OC::$server->get(LoggerInterface::class) + $this->connection->getQueryBuilder(), + $this->metadataManager, ); } + public function getStorageCache(): Storage { + return $this->storageCache; + } + /** * Get the numeric storage id for this cache's storage * @@ -146,35 +109,39 @@ class Cache implements ICache { /** * get the stored metadata of a file or folder * - * @param string | int $file either the path of a file or folder or the file id for a file or folder - * @return ICacheEntry|false the cache entry as array of false if the file is not found in the cache + * @param string|int $file either the path of a file or folder or the file id for a file or folder + * @return ICacheEntry|false the cache entry as array or false if the file is not found in the cache */ public function get($file) { $query = $this->getQueryBuilder(); $query->selectFileCache(); + $metadataQuery = $query->selectMetadata(); if (is_string($file) || $file == '') { // normalize file $file = $this->normalize($file); - $query->whereStorageId($this->getNumericStorageId()) - ->wherePath($file); + $query->wherePath($file); } else { //file id $query->whereFileId($file); } + $query->whereStorageId($this->getNumericStorageId()); - $result = $query->execute(); + $result = $query->executeQuery(); $data = $result->fetch(); $result->closeCursor(); - //merge partial data - if (!$data && is_string($file) && isset($this->partial[$file])) { - return $this->partial[$file]; - } elseif (!$data) { - return $data; - } else { + if ($data !== false) { + $data['metadata'] = $metadataQuery->extractMetadata($data)->asArray(); return self::cacheEntryFromData($data, $this->mimetypeLoader); + } else { + //merge partial data + if (is_string($file) && isset($this->partial[$file])) { + return $this->partial[$file]; + } } + + return false; } /** @@ -190,8 +157,8 @@ class Cache implements ICache { $data['path'] = (string)$data['path']; $data['fileid'] = (int)$data['fileid']; $data['parent'] = (int)$data['parent']; - $data['size'] = 0 + $data['size']; - $data['unencrypted_size'] = 0 + ($data['unencrypted_size'] ?? 0); + $data['size'] = Util::numericToNumber($data['size']); + $data['unencrypted_size'] = Util::numericToNumber($data['unencrypted_size'] ?? 0); $data['mtime'] = (int)$data['mtime']; $data['storage_mtime'] = (int)$data['storage_mtime']; $data['encryptedVersion'] = (int)$data['encrypted']; @@ -203,6 +170,9 @@ class Cache implements ICache { if ($data['storage_mtime'] == 0) { $data['storage_mtime'] = $data['mtime']; } + if (isset($data['f_permissions'])) { + $data['scan_permissions'] = $data['f_permissions']; + } $data['permissions'] = (int)$data['permissions']; if (isset($data['creation_time'])) { $data['creation_time'] = (int)$data['creation_time']; @@ -235,13 +205,17 @@ class Cache implements ICache { $query = $this->getQueryBuilder(); $query->selectFileCache() ->whereParent($fileId) + ->whereStorageId($this->getNumericStorageId()) ->orderBy('name', 'ASC'); - $result = $query->execute(); + $metadataQuery = $query->selectMetadata(); + + $result = $query->executeQuery(); $files = $result->fetchAll(); $result->closeCursor(); - return array_map(function (array $data) { + return array_map(function (array $data) use ($metadataQuery) { + $data['metadata'] = $metadataQuery->extractMetadata($data)->asArray(); return self::cacheEntryFromData($data, $this->mimetypeLoader); }, $files); } @@ -280,14 +254,14 @@ class Cache implements ICache { $file = $this->normalize($file); if (isset($this->partial[$file])) { //add any saved partial data - $data = array_merge($this->partial[$file], $data); + $data = array_merge($this->partial[$file]->getData(), $data); unset($this->partial[$file]); } $requiredFields = ['size', 'mtime', 'mimetype']; foreach ($requiredFields as $field) { if (!isset($data[$field])) { //data not complete save as partial and return - $this->partial[$file] = $data; + $this->partial[$file] = new CacheEntry($data); return -1; } } @@ -296,6 +270,9 @@ class Cache implements ICache { if (!isset($data['parent'])) { $data['parent'] = $this->getParentId($file); } + if ($data['parent'] === -1 && $file !== '') { + throw new \Exception('Parent folder not in filecache for ' . $file); + } $data['name'] = basename($file); [$values, $extensionValues] = $this->normalizeData($data); @@ -316,12 +293,13 @@ class Cache implements ICache { if (count($extensionValues)) { $query = $this->getQueryBuilder(); $query->insert('filecache_extended'); + $query->hintShardKey('storage', $storageId); $query->setValue('fileid', $query->createNamedParameter($fileId, IQueryBuilder::PARAM_INT)); foreach ($extensionValues as $column => $value) { $query->setValue($column, $query->createNamedParameter($value)); } - $query->execute(); + $query->executeStatement(); } $event = new CacheEntryInsertedEvent($this->storage, $file, $fileId, $storageId); @@ -370,6 +348,7 @@ class Cache implements ICache { $query->update('filecache') ->whereFileId($id) + ->whereStorageId($this->getNumericStorageId()) ->andWhere($query->expr()->orX(...array_map(function ($key, $value) use ($query) { return $query->expr()->orX( $query->expr()->neq($key, $query->createNamedParameter($value)), @@ -381,13 +360,14 @@ class Cache implements ICache { $query->set($key, $query->createNamedParameter($value)); } - $query->execute(); + $query->executeStatement(); } if (count($extensionValues)) { try { $query = $this->getQueryBuilder(); $query->insert('filecache_extended'); + $query->hintShardKey('storage', $this->getNumericStorageId()); $query->setValue('fileid', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT)); foreach ($extensionValues as $column => $value) { @@ -399,6 +379,7 @@ class Cache implements ICache { $query = $this->getQueryBuilder(); $query->update('filecache_extended') ->whereFileId($id) + ->hintShardKey('storage', $this->getNumericStorageId()) ->andWhere($query->expr()->orX(...array_map(function ($key, $value) use ($query) { return $query->expr()->orX( $query->expr()->neq($key, $query->createNamedParameter($value)), @@ -410,7 +391,7 @@ class Cache implements ICache { $query->set($key, $query->createNamedParameter($value)); } - $query->execute(); + $query->executeStatement(); } } @@ -445,7 +426,7 @@ class Cache implements ICache { $params = []; $extensionParams = []; foreach ($data as $name => $value) { - if (array_search($name, $fields) !== false) { + if (in_array($name, $fields)) { if ($name === 'path') { $params['path_hash'] = md5($value); } elseif ($name === 'mimetype') { @@ -465,7 +446,7 @@ class Cache implements ICache { } $params[$name] = $value; } - if (array_search($name, $extensionFields) !== false) { + if (in_array($name, $extensionFields)) { $extensionParams[$name] = $value; } } @@ -492,7 +473,7 @@ class Cache implements ICache { ->whereStorageId($this->getNumericStorageId()) ->wherePath($file); - $result = $query->execute(); + $result = $query->executeQuery(); $id = $result->fetchOne(); $result->closeCursor(); @@ -545,13 +526,15 @@ class Cache implements ICache { if ($entry instanceof ICacheEntry) { $query = $this->getQueryBuilder(); $query->delete('filecache') + ->whereStorageId($this->getNumericStorageId()) ->whereFileId($entry->getId()); - $query->execute(); + $query->executeStatement(); $query = $this->getQueryBuilder(); $query->delete('filecache_extended') - ->whereFileId($entry->getId()); - $query->execute(); + ->whereFileId($entry->getId()) + ->hintShardKey('storage', $this->getNumericStorageId()); + $query->executeStatement(); if ($entry->getMimeType() == FileInfo::MIMETYPE_FOLDER) { $this->removeChildren($entry); @@ -562,20 +545,7 @@ class Cache implements ICache { } /** - * Get all sub folders of a folder - * - * @param ICacheEntry $entry the cache entry of the folder to get the subfolders for - * @return ICacheEntry[] the cache entries for the subfolders - */ - private function getSubFolders(ICacheEntry $entry) { - $children = $this->getFolderContentsById($entry->getId()); - return array_filter($children, function ($child) { - return $child->getMimeType() == FileInfo::MIMETYPE_FOLDER; - }); - } - - /** - * Recursively remove all children of a folder + * Remove all children of a folder * * @param ICacheEntry $entry the cache entry of the folder to remove the children of * @throws \OC\DatabaseException @@ -583,6 +553,8 @@ class Cache implements ICache { private function removeChildren(ICacheEntry $entry) { $parentIds = [$entry->getId()]; $queue = [$entry->getId()]; + $deletedIds = []; + $deletedPaths = []; // we walk depth first through the file tree, removing all filecache_extended attributes while we walk // and collecting all folder ids to later use to delete the filecache entries @@ -591,20 +563,35 @@ class Cache implements ICache { $childIds = array_map(function (ICacheEntry $cacheEntry) { return $cacheEntry->getId(); }, $children); + $childPaths = array_map(function (ICacheEntry $cacheEntry) { + return $cacheEntry->getPath(); + }, $children); + + foreach ($childIds as $childId) { + $deletedIds[] = $childId; + } + + foreach ($childPaths as $childPath) { + $deletedPaths[] = $childPath; + } $query = $this->getQueryBuilder(); $query->delete('filecache_extended') - ->where($query->expr()->in('fileid', $query->createParameter('childIds'))); + ->where($query->expr()->in('fileid', $query->createParameter('childIds'))) + ->hintShardKey('storage', $this->getNumericStorageId()); foreach (array_chunk($childIds, 1000) as $childIdChunk) { $query->setParameter('childIds', $childIdChunk, IQueryBuilder::PARAM_INT_ARRAY); - $query->execute(); + $query->executeStatement(); } /** @var ICacheEntry[] $childFolders */ - $childFolders = array_filter($children, function ($child) { - return $child->getMimeType() == FileInfo::MIMETYPE_FOLDER; - }); + $childFolders = []; + foreach ($children as $child) { + if ($child->getMimeType() == FileInfo::MIMETYPE_FOLDER) { + $childFolders[] = $child; + } + } foreach ($childFolders as $folder) { $parentIds[] = $folder->getId(); $queue[] = $folder->getId(); @@ -613,11 +600,25 @@ class Cache implements ICache { $query = $this->getQueryBuilder(); $query->delete('filecache') + ->whereStorageId($this->getNumericStorageId()) ->whereParentInParameter('parentIds'); + // Sorting before chunking allows the db to find the entries close to each + // other in the index + sort($parentIds, SORT_NUMERIC); foreach (array_chunk($parentIds, 1000) as $parentIdChunk) { $query->setParameter('parentIds', $parentIdChunk, IQueryBuilder::PARAM_INT_ARRAY); - $query->execute(); + $query->executeStatement(); + } + + foreach (array_combine($deletedIds, $deletedPaths) as $fileId => $filePath) { + $cacheEntryRemovedEvent = new CacheEntryRemovedEvent( + $this->storage, + $filePath, + $fileId, + $this->getNumericStorageId() + ); + $this->eventDispatcher->dispatchTyped($cacheEntryRemovedEvent); } } @@ -641,6 +642,10 @@ class Cache implements ICache { return [$this->getNumericStorageId(), $path]; } + protected function hasEncryptionWrapper(): bool { + return $this->storage->instanceOfStorage(Encryption::class); + } + /** * Move a file or folder in the cache * @@ -657,8 +662,17 @@ class Cache implements ICache { $targetPath = $this->normalize($targetPath); $sourceData = $sourceCache->get($sourcePath); - if ($sourceData === false) { - throw new \Exception('Invalid source storage path: ' . $sourcePath); + if (!$sourceData) { + throw new \Exception('Source path not found in cache: ' . $sourcePath); + } + + $shardDefinition = $this->connection->getShardDefinition('filecache'); + if ( + $shardDefinition + && $shardDefinition->getShardForKey($sourceCache->getNumericStorageId()) !== $shardDefinition->getShardForKey($this->getNumericStorageId()) + ) { + $this->moveFromStorageSharded($shardDefinition, $sourceCache, $sourceData, $targetPath); + return; } $sourceId = $sourceData['fileid']; @@ -674,11 +688,15 @@ class Cache implements ICache { throw new \Exception('Invalid target storage id: ' . $targetStorageId); } - $this->connection->beginTransaction(); if ($sourceData['mimetype'] === 'httpd/unix-directory') { //update all child entries $sourceLength = mb_strlen($sourcePath); - $query = $this->connection->getQueryBuilder(); + + $childIds = $this->getChildIds($sourceStorageId, $sourcePath); + + $childChunks = array_chunk($childIds, 1000); + + $query = $this->getQueryBuilder(); $fun = $query->func(); $newPathFunction = $fun->concat( @@ -686,29 +704,73 @@ class Cache implements ICache { $fun->substring('path', $query->createNamedParameter($sourceLength + 1, IQueryBuilder::PARAM_INT))// +1 for the leading slash ); $query->update('filecache') - ->set('storage', $query->createNamedParameter($targetStorageId, IQueryBuilder::PARAM_INT)) ->set('path_hash', $fun->md5($newPathFunction)) ->set('path', $newPathFunction) - ->where($query->expr()->eq('storage', $query->createNamedParameter($sourceStorageId, IQueryBuilder::PARAM_INT))) - ->andWhere($query->expr()->like('path', $query->createNamedParameter($this->connection->escapeLikeParameter($sourcePath) . '/%'))); - - try { - $query->execute(); - } catch (\OC\DatabaseException $e) { - $this->connection->rollBack(); - throw $e; + ->whereStorageId($sourceStorageId) + ->andWhere($query->expr()->in('fileid', $query->createParameter('files'))); + + if ($sourceStorageId !== $targetStorageId) { + $query->set('storage', $query->createNamedParameter($targetStorageId), IQueryBuilder::PARAM_INT); + } + + // when moving from an encrypted storage to a non-encrypted storage remove the `encrypted` mark + if ($sourceCache->hasEncryptionWrapper() && !$this->hasEncryptionWrapper()) { + $query->set('encrypted', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT)); } + + // Retry transaction in case of RetryableException like deadlocks. + // Retry up to 4 times because we should receive up to 4 concurrent requests from the frontend + $retryLimit = 4; + for ($i = 1; $i <= $retryLimit; $i++) { + try { + $this->connection->beginTransaction(); + foreach ($childChunks as $chunk) { + $query->setParameter('files', $chunk, IQueryBuilder::PARAM_INT_ARRAY); + $query->executeStatement(); + } + break; + } catch (\OC\DatabaseException $e) { + $this->connection->rollBack(); + throw $e; + } catch (DbalException $e) { + $this->connection->rollBack(); + + if (!$e->isRetryable()) { + throw $e; + } + + // Simply throw if we already retried 4 times. + if ($i === $retryLimit) { + throw $e; + } + + // Sleep a bit to give some time to the other transaction to finish. + usleep(100 * 1000 * $i); + } + } + } else { + $this->connection->beginTransaction(); } $query = $this->getQueryBuilder(); $query->update('filecache') - ->set('storage', $query->createNamedParameter($targetStorageId)) ->set('path', $query->createNamedParameter($targetPath)) ->set('path_hash', $query->createNamedParameter(md5($targetPath))) ->set('name', $query->createNamedParameter(basename($targetPath))) ->set('parent', $query->createNamedParameter($newParentId, IQueryBuilder::PARAM_INT)) + ->whereStorageId($sourceStorageId) ->whereFileId($sourceId); - $query->execute(); + + if ($sourceStorageId !== $targetStorageId) { + $query->set('storage', $query->createNamedParameter($targetStorageId), IQueryBuilder::PARAM_INT); + } + + // when moving from an encrypted storage to a non-encrypted storage remove the `encrypted` mark + if ($sourceCache->hasEncryptionWrapper() && !$this->hasEncryptionWrapper()) { + $query->set('encrypted', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT)); + } + + $query->executeStatement(); $this->connection->commit(); @@ -727,6 +789,15 @@ class Cache implements ICache { } } + private function getChildIds(int $storageId, string $path): array { + $query = $this->connection->getQueryBuilder(); + $query->select('fileid') + ->from('filecache') + ->where($query->expr()->eq('storage', $query->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->like('path', $query->createNamedParameter($this->connection->escapeLikeParameter($path) . '/%'))); + return $query->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); + } + /** * remove all entries for files that are stored on the storage from the cache */ @@ -734,12 +805,12 @@ class Cache implements ICache { $query = $this->getQueryBuilder(); $query->delete('filecache') ->whereStorageId($this->getNumericStorageId()); - $query->execute(); + $query->executeStatement(); $query = $this->connection->getQueryBuilder(); $query->delete('storages') ->where($query->expr()->eq('id', $query->createNamedParameter($this->storageId))); - $query->execute(); + $query->executeStatement(); } /** @@ -764,7 +835,7 @@ class Cache implements ICache { ->whereStorageId($this->getNumericStorageId()) ->wherePath($file); - $result = $query->execute(); + $result = $query->executeQuery(); $size = $result->fetchOne(); $result->closeCursor(); @@ -798,11 +869,11 @@ class Cache implements ICache { * search for files by mimetype * * @param string $mimetype either a full mimetype to search ('text/plain') or only the first part of a mimetype ('image') - * where it will search for all mimetypes in the group ('image/*') + * where it will search for all mimetypes in the group ('image/*') * @return ICacheEntry[] an array of cache entries where the mimetype matches the search */ public function searchByMime($mimetype) { - if (strpos($mimetype, '/') === false) { + if (!str_contains($mimetype, '/')) { $operator = new SearchComparison(ISearchComparison::COMPARE_LIKE, 'mimetype', $mimetype . '/%'); } else { $operator = new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'mimetype', $mimetype); @@ -810,26 +881,30 @@ class Cache implements ICache { return $this->searchQuery(new SearchQuery($operator, 0, 0, [], null)); } - public function searchQuery(ISearchQuery $searchQuery) { - return current($this->querySearchHelper->searchInCaches($searchQuery, [$this])); + public function searchQuery(ISearchQuery $query) { + return current($this->querySearchHelper->searchInCaches($query, [$this])); } /** * Re-calculate the folder size and the size of all parent folders * - * @param string|boolean $path - * @param array $data (optional) meta data of the folder + * @param array|ICacheEntry|null $data (optional) meta data of the folder */ - public function correctFolderSize($path, $data = null, $isBackgroundScan = false) { + public function correctFolderSize(string $path, $data = null, bool $isBackgroundScan = false): void { $this->calculateFolderSize($path, $data); + if ($path !== '') { $parent = dirname($path); if ($parent === '.' || $parent === '/') { $parent = ''; } + if ($isBackgroundScan) { $parentData = $this->get($parent); - if ($parentData['size'] !== -1 && $this->getIncompleteChildrenCount($parentData['fileid']) === 0) { + if ($parentData !== false + && $parentData['size'] !== -1 + && $this->getIncompleteChildrenCount($parentData['fileid']) === 0 + ) { $this->correctFolderSize($parent, $parentData, $isBackgroundScan); } } else { @@ -850,9 +925,10 @@ class Cache implements ICache { $query->select($query->func()->count()) ->from('filecache') ->whereParent($fileId) - ->andWhere($query->expr()->lt('size', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + ->whereStorageId($this->getNumericStorageId()) + ->andWhere($query->expr()->eq('size', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT))); - $result = $query->execute(); + $result = $query->executeQuery(); $size = (int)$result->fetchOne(); $result->closeCursor(); @@ -865,10 +941,23 @@ class Cache implements ICache { * calculate the size of a folder and set it in the cache * * @param string $path - * @param array $entry (optional) meta data of the folder - * @return int + * @param array|null|ICacheEntry $entry (optional) meta data of the folder + * @return int|float */ public function calculateFolderSize($path, $entry = null) { + return $this->calculateFolderSizeInner($path, $entry); + } + + + /** + * inner function because we can't add new params to the public function without breaking any child classes + * + * @param string $path + * @param array|null|ICacheEntry $entry (optional) meta data of the folder + * @param bool $ignoreUnknown don't mark the folder size as unknown if any of it's children are unknown + * @return int|float + */ + protected function calculateFolderSizeInner(string $path, $entry = null, bool $ignoreUnknown = false) { $totalSize = 0; if (is_null($entry) || !isset($entry['fileid'])) { $entry = $this->get($path); @@ -879,21 +968,25 @@ class Cache implements ICache { $query = $this->getQueryBuilder(); $query->select('size', 'unencrypted_size') ->from('filecache') + ->whereStorageId($this->getNumericStorageId()) ->whereParent($id); + if ($ignoreUnknown) { + $query->andWhere($query->expr()->gte('size', $query->createNamedParameter(0))); + } - $result = $query->execute(); + $result = $query->executeQuery(); $rows = $result->fetchAll(); $result->closeCursor(); if ($rows) { $sizes = array_map(function (array $row) { - return (int)$row['size']; + return Util::numericToNumber($row['size']); }, $rows); $unencryptedOnlySizes = array_map(function (array $row) { - return (int)$row['unencrypted_size']; + return Util::numericToNumber($row['unencrypted_size']); }, $rows); $unencryptedSizes = array_map(function (array $row) { - return (int)(($row['unencrypted_size'] > 0) ? $row['unencrypted_size'] : $row['size']); + return Util::numericToNumber(($row['unencrypted_size'] > 0) ? $row['unencrypted_size'] : $row['size']); }, $rows); $sum = array_sum($sizes); @@ -920,9 +1013,16 @@ class Cache implements ICache { $unencryptedTotal = 0; $unencryptedMax = 0; } - if ($entry['size'] !== $totalSize) { - // only set unencrypted size for a folder if any child entries have it set, or the folder is empty - if ($unencryptedMax > 0 || $totalSize === 0) { + + // only set unencrypted size for a folder if any child entries have it set, or the folder is empty + $shouldWriteUnEncryptedSize = $unencryptedMax > 0 || $totalSize === 0 || ($entry['unencrypted_size'] ?? 0) > 0; + if ($entry['size'] !== $totalSize || (($entry['unencrypted_size'] ?? 0) !== $unencryptedTotal && $shouldWriteUnEncryptedSize)) { + if ($shouldWriteUnEncryptedSize) { + // if all children have an unencrypted size of 0, just set the folder unencrypted size to 0 instead of summing the sizes + if ($unencryptedMax === 0) { + $unencryptedTotal = 0; + } + $this->update($id, [ 'size' => $totalSize, 'unencrypted_size' => $unencryptedTotal, @@ -948,7 +1048,7 @@ class Cache implements ICache { ->from('filecache') ->whereStorageId($this->getNumericStorageId()); - $result = $query->execute(); + $result = $query->executeQuery(); $files = $result->fetchAll(\PDO::FETCH_COLUMN); $result->closeCursor(); @@ -971,20 +1071,15 @@ class Cache implements ICache { $query->select('path') ->from('filecache') ->whereStorageId($this->getNumericStorageId()) - ->andWhere($query->expr()->lt('size', $query->createNamedParameter(0, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq('size', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT))) ->orderBy('fileid', 'DESC') ->setMaxResults(1); - $result = $query->execute(); + $result = $query->executeQuery(); $path = $result->fetchOne(); $result->closeCursor(); - if ($path === false) { - return false; - } - - // Make sure Oracle does not continue with null for empty strings - return (string)$path; + return $path === false ? false : (string)$path; } /** @@ -1000,7 +1095,7 @@ class Cache implements ICache { ->whereStorageId($this->getNumericStorageId()) ->whereFileId($id); - $result = $query->execute(); + $result = $query->executeQuery(); $path = $result->fetchOne(); $result->closeCursor(); @@ -1018,7 +1113,7 @@ class Cache implements ICache { * * @param int $id * @return array first element holding the storage id, second the path - * @deprecated use getPathById() instead + * @deprecated 17.0.0 use getPathById() instead */ public static function getById($id) { $query = \OC::$server->getDatabaseConnection()->getQueryBuilder(); @@ -1026,7 +1121,7 @@ class Cache implements ICache { ->from('filecache') ->where($query->expr()->eq('fileid', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT))); - $result = $query->execute(); + $result = $query->executeQuery(); $row = $result->fetch(); $result->closeCursor(); @@ -1064,12 +1159,18 @@ class Cache implements ICache { */ public function copyFromCache(ICache $sourceCache, ICacheEntry $sourceEntry, string $targetPath): int { if ($sourceEntry->getId() < 0) { - throw new \RuntimeException("Invalid source cache entry on copyFromCache"); + throw new \RuntimeException('Invalid source cache entry on copyFromCache'); } $data = $this->cacheEntryToArray($sourceEntry); + + // when moving from an encrypted storage to a non-encrypted storage remove the `encrypted` mark + if ($sourceCache instanceof Cache && $sourceCache->hasEncryptionWrapper() && !$this->hasEncryptionWrapper()) { + $data['encrypted'] = 0; + } + $fileId = $this->put($targetPath, $data); if ($fileId <= 0) { - throw new \RuntimeException("Failed to copy to " . $targetPath . " from cache with source data " . json_encode($data) . " "); + throw new \RuntimeException('Failed to copy to ' . $targetPath . ' from cache with source data ' . json_encode($data) . ' '); } if ($sourceEntry->getMimeType() === ICacheEntry::DIRECTORY_MIMETYPE) { $folderContent = $sourceCache->getFolderContentsById($sourceEntry->getId()); @@ -1082,7 +1183,7 @@ class Cache implements ICache { } private function cacheEntryToArray(ICacheEntry $entry): array { - return [ + $data = [ 'size' => $entry->getSize(), 'mtime' => $entry->getMTime(), 'storage_mtime' => $entry->getStorageMTime(), @@ -1095,6 +1196,10 @@ class Cache implements ICache { 'upload_time' => $entry->getUploadTime(), 'metadata_etag' => $entry->getMetadataEtag(), ]; + if ($entry instanceof CacheEntry && isset($entry['scan_permissions'])) { + $data['permissions'] = $entry['scan_permissions']; + } + return $data; } public function getQueryFilterForStorage(): ISearchOperator { @@ -1108,4 +1213,72 @@ class Cache implements ICache { return null; } } + + private function moveFromStorageSharded(ShardDefinition $shardDefinition, ICache $sourceCache, ICacheEntry $sourceEntry, $targetPath): void { + if ($sourceEntry->getMimeType() === ICacheEntry::DIRECTORY_MIMETYPE) { + $fileIds = $this->getChildIds($sourceCache->getNumericStorageId(), $sourceEntry->getPath()); + } else { + $fileIds = []; + } + $fileIds[] = $sourceEntry->getId(); + + $helper = $this->connection->getCrossShardMoveHelper(); + + $sourceConnection = $helper->getConnection($shardDefinition, $sourceCache->getNumericStorageId()); + $targetConnection = $helper->getConnection($shardDefinition, $this->getNumericStorageId()); + + $cacheItems = $helper->loadItems($sourceConnection, 'filecache', 'fileid', $fileIds); + $extendedItems = $helper->loadItems($sourceConnection, 'filecache_extended', 'fileid', $fileIds); + $metadataItems = $helper->loadItems($sourceConnection, 'files_metadata', 'file_id', $fileIds); + + // when moving from an encrypted storage to a non-encrypted storage remove the `encrypted` mark + $removeEncryptedFlag = ($sourceCache instanceof Cache && $sourceCache->hasEncryptionWrapper()) && !$this->hasEncryptionWrapper(); + + $sourcePathLength = strlen($sourceEntry->getPath()); + foreach ($cacheItems as &$cacheItem) { + if ($cacheItem['path'] === $sourceEntry->getPath()) { + $cacheItem['path'] = $targetPath; + $cacheItem['parent'] = $this->getParentId($targetPath); + $cacheItem['name'] = basename($cacheItem['path']); + } else { + $cacheItem['path'] = $targetPath . '/' . substr($cacheItem['path'], $sourcePathLength + 1); // +1 for the leading slash + } + $cacheItem['path_hash'] = md5($cacheItem['path']); + $cacheItem['storage'] = $this->getNumericStorageId(); + if ($removeEncryptedFlag) { + $cacheItem['encrypted'] = 0; + } + } + + $targetConnection->beginTransaction(); + + try { + $helper->saveItems($targetConnection, 'filecache', $cacheItems); + $helper->saveItems($targetConnection, 'filecache_extended', $extendedItems); + $helper->saveItems($targetConnection, 'files_metadata', $metadataItems); + } catch (\Exception $e) { + $targetConnection->rollback(); + throw $e; + } + + $sourceConnection->beginTransaction(); + + try { + $helper->deleteItems($sourceConnection, 'filecache', 'fileid', $fileIds); + $helper->deleteItems($sourceConnection, 'filecache_extended', 'fileid', $fileIds); + $helper->deleteItems($sourceConnection, 'files_metadata', 'file_id', $fileIds); + } catch (\Exception $e) { + $targetConnection->rollback(); + $sourceConnection->rollBack(); + throw $e; + } + + try { + $sourceConnection->commit(); + } catch (\Exception $e) { + $targetConnection->rollback(); + throw $e; + } + $targetConnection->commit(); + } } diff --git a/lib/private/Files/Cache/CacheDependencies.php b/lib/private/Files/Cache/CacheDependencies.php new file mode 100644 index 00000000000..61d2a2f9646 --- /dev/null +++ b/lib/private/Files/Cache/CacheDependencies.php @@ -0,0 +1,61 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files\Cache; + +use OC\SystemConfig; +use OC\User\DisplayNameCache; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\IMimeTypeLoader; +use OCP\FilesMetadata\IFilesMetadataManager; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; + +class CacheDependencies { + public function __construct( + private IMimeTypeLoader $mimeTypeLoader, + private IDBConnection $connection, + private IEventDispatcher $eventDispatcher, + private QuerySearchHelper $querySearchHelper, + private SystemConfig $systemConfig, + private LoggerInterface $logger, + private IFilesMetadataManager $metadataManager, + private DisplayNameCache $displayNameCache, + ) { + } + + public function getMimeTypeLoader(): IMimeTypeLoader { + return $this->mimeTypeLoader; + } + + public function getConnection(): IDBConnection { + return $this->connection; + } + + public function getEventDispatcher(): IEventDispatcher { + return $this->eventDispatcher; + } + + public function getQuerySearchHelper(): QuerySearchHelper { + return $this->querySearchHelper; + } + + public function getSystemConfig(): SystemConfig { + return $this->systemConfig; + } + + public function getLogger(): LoggerInterface { + return $this->logger; + } + + public function getDisplayNameCache(): DisplayNameCache { + return $this->displayNameCache; + } + + public function getMetadataManager(): IFilesMetadataManager { + return $this->metadataManager; + } +} diff --git a/lib/private/Files/Cache/CacheEntry.php b/lib/private/Files/Cache/CacheEntry.php index 3c93296ff62..ab5bae316f4 100644 --- a/lib/private/Files/Cache/CacheEntry.php +++ b/lib/private/Files/Cache/CacheEntry.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Robin Appelman <robin@icewind.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Cache; @@ -114,15 +99,19 @@ class CacheEntry implements ICacheEntry { } public function getMetadataEtag(): ?string { - return $this->data['metadata_etag']; + return $this->data['metadata_etag'] ?? null; } public function getCreationTime(): ?int { - return $this->data['creation_time']; + return $this->data['creation_time'] ?? null; } public function getUploadTime(): ?int { - return $this->data['upload_time']; + return $this->data['upload_time'] ?? null; + } + + public function getParentId(): int { + return $this->data['parent']; } public function getData() { @@ -134,7 +123,7 @@ class CacheEntry implements ICacheEntry { } public function getUnencryptedSize(): int { - if (isset($this->data['unencrypted_size']) && $this->data['unencrypted_size'] > 0) { + if ($this->data['encrypted'] && isset($this->data['unencrypted_size']) && $this->data['unencrypted_size'] > 0) { return $this->data['unencrypted_size']; } else { return $this->data['size'] ?? 0; diff --git a/lib/private/Files/Cache/CacheQueryBuilder.php b/lib/private/Files/Cache/CacheQueryBuilder.php index 496a8361d77..5492452273b 100644 --- a/lib/private/Files/Cache/CacheQueryBuilder.php +++ b/lib/private/Files/Cache/CacheQueryBuilder.php @@ -3,48 +3,52 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Cache; -use OC\DB\QueryBuilder\QueryBuilder; -use OC\SystemConfig; +use OC\DB\QueryBuilder\ExtendedQueryBuilder; use OCP\DB\QueryBuilder\IQueryBuilder; -use OCP\IDBConnection; -use Psr\Log\LoggerInterface; +use OCP\FilesMetadata\IFilesMetadataManager; +use OCP\FilesMetadata\IMetadataQuery; /** * Query builder with commonly used helpers for filecache queries */ -class CacheQueryBuilder extends QueryBuilder { - private $alias = null; +class CacheQueryBuilder extends ExtendedQueryBuilder { + private ?string $alias = null; + + public function __construct( + IQueryBuilder $queryBuilder, + private IFilesMetadataManager $filesMetadataManager, + ) { + parent::__construct($queryBuilder); + } + + public function selectTagUsage(): self { + $this + ->select('systemtag.name', 'systemtag.id', 'systemtag.visibility', 'systemtag.editable', 'systemtag.etag', 'systemtag.color') + ->selectAlias($this->createFunction('COUNT(filecache.fileid)'), 'number_files') + ->selectAlias($this->createFunction('MAX(filecache.fileid)'), 'ref_file_id') + ->from('filecache', 'filecache') + ->leftJoin('filecache', 'systemtag_object_mapping', 'systemtagmap', $this->expr()->andX( + $this->expr()->eq('filecache.fileid', $this->expr()->castColumn('systemtagmap.objectid', IQueryBuilder::PARAM_INT)), + $this->expr()->eq('systemtagmap.objecttype', $this->createNamedParameter('files')) + )) + ->leftJoin('systemtagmap', 'systemtag', 'systemtag', $this->expr()->andX( + $this->expr()->eq('systemtag.id', 'systemtagmap.systemtagid'), + $this->expr()->eq('systemtag.visibility', $this->createNamedParameter(true)) + )) + ->groupBy('systemtag.name', 'systemtag.id', 'systemtag.visibility', 'systemtag.editable'); - public function __construct(IDBConnection $connection, SystemConfig $systemConfig, LoggerInterface $logger) { - parent::__construct($connection, $systemConfig, $logger); + return $this; } - public function selectFileCache(string $alias = null, bool $joinExtendedCache = true) { - $name = $alias ? $alias : 'filecache'; + public function selectFileCache(?string $alias = null, bool $joinExtendedCache = true) { + $name = $alias ?: 'filecache'; $this->select("$name.fileid", 'storage', 'path', 'path_hash', "$name.parent", "$name.name", 'mimetype', 'mimepart', 'size', 'mtime', - 'storage_mtime', 'encrypted', 'etag', 'permissions', 'checksum', 'unencrypted_size') + 'storage_mtime', 'encrypted', "$name.etag", "$name.permissions", 'checksum', 'unencrypted_size') ->from('filecache', $name); if ($joinExtendedCache) { @@ -107,4 +111,15 @@ class CacheQueryBuilder extends QueryBuilder { return $this; } + + /** + * join metadata to current query builder and returns an helper + * + * @return IMetadataQuery + */ + public function selectMetadata(): IMetadataQuery { + $metadataQuery = $this->filesMetadataManager->getMetadataQuery($this, $this->alias, 'fileid'); + $metadataQuery->retrieveMetadata(); + return $metadataQuery; + } } diff --git a/lib/private/Files/Cache/FailedCache.php b/lib/private/Files/Cache/FailedCache.php index d60e09ea329..44c1016ca8e 100644 --- a/lib/private/Files/Cache/FailedCache.php +++ b/lib/private/Files/Cache/FailedCache.php @@ -1,23 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Cache; @@ -139,7 +125,7 @@ class FailedCache implements ICache { } public function copyFromCache(ICache $sourceCache, ICacheEntry $sourceEntry, string $targetPath): int { - throw new \Exception("Invalid cache"); + throw new \Exception('Invalid cache'); } public function getQueryFilterForStorage(): ISearchOperator { diff --git a/lib/private/Files/Cache/FileAccess.php b/lib/private/Files/Cache/FileAccess.php new file mode 100644 index 00000000000..c3f3614f3ca --- /dev/null +++ b/lib/private/Files/Cache/FileAccess.php @@ -0,0 +1,222 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files\Cache; + +use OC\FilesMetadata\FilesMetadataManager; +use OC\SystemConfig; +use OCP\DB\Exception; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Files\Cache\IFileAccess; +use OCP\Files\IMimeTypeLoader; +use OCP\IDBConnection; +use Psr\Log\LoggerInterface; + +/** + * Low level access to the file cache + */ +class FileAccess implements IFileAccess { + public function __construct( + private IDBConnection $connection, + private SystemConfig $systemConfig, + private LoggerInterface $logger, + private FilesMetadataManager $metadataManager, + private IMimeTypeLoader $mimeTypeLoader, + ) { + } + + private function getQuery(): CacheQueryBuilder { + return new CacheQueryBuilder( + $this->connection->getQueryBuilder(), + $this->metadataManager, + ); + } + + public function getByFileIdInStorage(int $fileId, int $storageId): ?CacheEntry { + $items = array_values($this->getByFileIdsInStorage([$fileId], $storageId)); + return $items[0] ?? null; + } + + public function getByPathInStorage(string $path, int $storageId): ?CacheEntry { + $query = $this->getQuery()->selectFileCache(); + $query->andWhere($query->expr()->eq('filecache.path_hash', $query->createNamedParameter(md5($path)))); + $query->andWhere($query->expr()->eq('filecache.storage', $query->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))); + + $row = $query->executeQuery()->fetch(); + return $row ? Cache::cacheEntryFromData($row, $this->mimeTypeLoader) : null; + } + + public function getByFileId(int $fileId): ?CacheEntry { + $items = array_values($this->getByFileIds([$fileId])); + return $items[0] ?? null; + } + + /** + * @param array[] $rows + * @return array<int, CacheEntry> + */ + private function rowsToEntries(array $rows): array { + $result = []; + foreach ($rows as $row) { + $entry = Cache::cacheEntryFromData($row, $this->mimeTypeLoader); + $result[$entry->getId()] = $entry; + } + return $result; + } + + /** + * @param int[] $fileIds + * @return array<int, CacheEntry> + */ + public function getByFileIds(array $fileIds): array { + $query = $this->getQuery()->selectFileCache(); + $query->andWhere($query->expr()->in('filecache.fileid', $query->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY))); + + $rows = $query->executeQuery()->fetchAll(); + return $this->rowsToEntries($rows); + } + + /** + * @param int[] $fileIds + * @param int $storageId + * @return array<int, CacheEntry> + */ + public function getByFileIdsInStorage(array $fileIds, int $storageId): array { + $fileIds = array_values($fileIds); + $query = $this->getQuery()->selectFileCache(); + $query->andWhere($query->expr()->in('filecache.fileid', $query->createNamedParameter($fileIds, IQueryBuilder::PARAM_INT_ARRAY))); + $query->andWhere($query->expr()->eq('filecache.storage', $query->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))); + + $rows = $query->executeQuery()->fetchAll(); + return $this->rowsToEntries($rows); + } + + public function getByAncestorInStorage(int $storageId, int $folderId, int $fileIdCursor = 0, int $maxResults = 100, array $mimeTypeIds = [], bool $endToEndEncrypted = true, bool $serverSideEncrypted = true): \Generator { + $qb = $this->getQuery(); + $qb->select('path') + ->from('filecache') + ->where($qb->expr()->eq('fileid', $qb->createNamedParameter($folderId, IQueryBuilder::PARAM_INT))); + $result = $qb->executeQuery(); + /** @var array{path:string}|false $root */ + $root = $result->fetch(); + $result->closeCursor(); + + if ($root === false) { + throw new Exception('Could not fetch storage root'); + } + + $qb = $this->getQuery(); + + $path = $root['path'] === '' ? '' : $root['path'] . '/'; + + $qb->selectDistinct('*') + ->from('filecache', 'f') + ->where($qb->expr()->like('f.path', $qb->createNamedParameter($this->connection->escapeLikeParameter($path) . '%'))) + ->andWhere($qb->expr()->eq('f.storage', $qb->createNamedParameter($storageId))) + ->andWhere($qb->expr()->gt('f.fileid', $qb->createNamedParameter($fileIdCursor, IQueryBuilder::PARAM_INT))); + + if (!$endToEndEncrypted) { + // End to end encrypted files are descendants of a folder with encrypted=1 + // Use a subquery to check the `encrypted` status of the parent folder + $subQuery = $this->getQuery()->select('p.encrypted') + ->from('filecache', 'p') + ->andWhere($qb->expr()->eq('p.fileid', 'f.parent')) + ->getSQL(); + + $qb->andWhere( + $qb->expr()->eq($qb->createFunction(sprintf('(%s)', $subQuery)), $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT)) + ); + } + + if (!$serverSideEncrypted) { + // Server side encrypted files have encrypted=1 directly + $qb->andWhere($qb->expr()->eq('f.encrypted', $qb->createNamedParameter(0, IQueryBuilder::PARAM_INT))); + } + + if (count($mimeTypeIds) > 0) { + $qb->andWhere($qb->expr()->in('f.mimetype', $qb->createNamedParameter($mimeTypeIds, IQueryBuilder::PARAM_INT_ARRAY))); + } + + if ($maxResults !== 0) { + $qb->setMaxResults($maxResults); + } + $qb->orderBy('f.fileid', 'ASC'); + $files = $qb->executeQuery(); + + while ( + /** @var array */ + $row = $files->fetch() + ) { + yield Cache::cacheEntryFromData($row, $this->mimeTypeLoader); + } + + $files->closeCursor(); + } + + public function getDistinctMounts(array $mountProviders = [], bool $onlyUserFilesMounts = true): \Generator { + $qb = $this->connection->getQueryBuilder(); + $qb->selectDistinct(['root_id', 'storage_id', 'mount_provider_class']) + ->from('mounts'); + if ($onlyUserFilesMounts) { + $qb->andWhere( + $qb->expr()->orX( + $qb->expr()->like('mount_point', $qb->createNamedParameter('/%/files/%')), + $qb->expr()->in('mount_provider_class', $qb->createNamedParameter([ + \OC\Files\Mount\LocalHomeMountProvider::class, + \OC\Files\Mount\ObjectHomeMountProvider::class, + ], IQueryBuilder::PARAM_STR_ARRAY)) + ) + ); + } + if (count($mountProviders) > 0) { + $qb->andWhere($qb->expr()->in('mount_provider_class', $qb->createNamedParameter($mountProviders, IQueryBuilder::PARAM_STR_ARRAY))); + } + $qb->orderBy('root_id', 'ASC'); + $result = $qb->executeQuery(); + + while ( + /** @var array{storage_id:int, root_id:int,mount_provider_class:string} $row */ + $row = $result->fetch() + ) { + $storageId = (int)$row['storage_id']; + $rootId = (int)$row['root_id']; + $overrideRoot = $rootId; + // LocalHomeMountProvider is the default provider for user home directories + // ObjectHomeMountProvider is the home directory provider for when S3 primary storage is used + if ($onlyUserFilesMounts && in_array($row['mount_provider_class'], [ + \OC\Files\Mount\LocalHomeMountProvider::class, + \OC\Files\Mount\ObjectHomeMountProvider::class, + ], true)) { + // Only crawl files, not cache or trashbin + $qb = $this->getQuery(); + try { + $qb->select('fileid') + ->from('filecache') + ->where($qb->expr()->eq('storage', $qb->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('parent', $qb->createNamedParameter($rootId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('path', $qb->createNamedParameter('files'))); + /** @var array|false $root */ + $root = $qb->executeQuery()->fetch(); + if ($root !== false) { + $overrideRoot = (int)$root['fileid']; + } + } catch (Exception $e) { + $this->logger->error('Could not fetch home storage files root for storage ' . $storageId, ['exception' => $e]); + continue; + } + } + // Reference to root_id is still necessary even if we have the overridden_root_id, because storage_id and root_id uniquely identify a mount + yield [ + 'storage_id' => $storageId, + 'root_id' => $rootId, + 'overridden_root' => $overrideRoot, + ]; + } + $result->closeCursor(); + } +} diff --git a/lib/private/Files/Cache/HomeCache.php b/lib/private/Files/Cache/HomeCache.php index 6b6b94c3f20..248cdc818f0 100644 --- a/lib/private/Files/Cache/HomeCache.php +++ b/lib/private/Files/Cache/HomeCache.php @@ -1,30 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Andreas Fischer <bantu@owncloud.com> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Cache; @@ -35,8 +14,8 @@ class HomeCache extends Cache { * get the size of a folder and set it in the cache * * @param string $path - * @param array $entry (optional) meta data of the folder - * @return int + * @param array|null|ICacheEntry $entry (optional) meta data of the folder + * @return int|float */ public function calculateFolderSize($path, $entry = null) { if ($path !== '/' and $path !== '' and $path !== 'files' and $path !== 'files_trashbin' and $path !== 'files_versions') { @@ -44,46 +23,18 @@ class HomeCache extends Cache { } elseif ($path === '' or $path === '/') { // since the size of / isn't used (the size of /files is used instead) there is no use in calculating it return 0; + } else { + return $this->calculateFolderSizeInner($path, $entry, true); } - - $totalSize = 0; - if (is_null($entry)) { - $entry = $this->get($path); - } - if ($entry && $entry['mimetype'] === 'httpd/unix-directory') { - $id = $entry['fileid']; - - $query = $this->connection->getQueryBuilder(); - $query->selectAlias($query->func()->sum('size'), 'f1') - ->from('filecache') - ->where($query->expr()->eq('parent', $query->createNamedParameter($id))) - ->andWhere($query->expr()->eq('storage', $query->createNamedParameter($this->getNumericStorageId()))) - ->andWhere($query->expr()->gte('size', $query->createNamedParameter(0))); - - $result = $query->execute(); - $row = $result->fetch(); - $result->closeCursor(); - - if ($row) { - [$sum] = array_values($row); - $totalSize = 0 + $sum; - $entry['size'] += 0; - if ($entry['size'] !== $totalSize) { - $this->update($id, ['size' => $totalSize]); - } - } - $result->closeCursor(); - } - return $totalSize; } /** - * @param string $path + * @param string $file * @return ICacheEntry */ - public function get($path) { - $data = parent::get($path); - if ($path === '' or $path === '/') { + public function get($file) { + $data = parent::get($file); + if ($file === '' or $file === '/') { // only the size of the "files" dir counts $filesData = parent::get('files'); diff --git a/lib/private/Files/Cache/HomePropagator.php b/lib/private/Files/Cache/HomePropagator.php index 6dba09d756b..d4ac8a7c8e3 100644 --- a/lib/private/Files/Cache/HomePropagator.php +++ b/lib/private/Files/Cache/HomePropagator.php @@ -1,23 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Cache; diff --git a/lib/private/Files/Cache/LocalRootScanner.php b/lib/private/Files/Cache/LocalRootScanner.php index 0b6bc497ea3..3f4f70b865b 100644 --- a/lib/private/Files/Cache/LocalRootScanner.php +++ b/lib/private/Files/Cache/LocalRootScanner.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Cache; @@ -44,6 +27,6 @@ class LocalRootScanner extends Scanner { private function shouldScanPath(string $path): bool { $path = trim($path, '/'); - return $path === '' || strpos($path, 'appdata_') === 0 || strpos($path, '__groupfolders') === 0; + return $path === '' || str_starts_with($path, 'appdata_') || str_starts_with($path, '__groupfolders'); } } diff --git a/lib/private/Files/Cache/MoveFromCacheTrait.php b/lib/private/Files/Cache/MoveFromCacheTrait.php index 77cd7ea6d8c..db35c6bb7f8 100644 --- a/lib/private/Files/Cache/MoveFromCacheTrait.php +++ b/lib/private/Files/Cache/MoveFromCacheTrait.php @@ -1,23 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Cache; diff --git a/lib/private/Files/Cache/NullWatcher.php b/lib/private/Files/Cache/NullWatcher.php index 2e83c1006bb..e3659214849 100644 --- a/lib/private/Files/Cache/NullWatcher.php +++ b/lib/private/Files/Cache/NullWatcher.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Cache; diff --git a/lib/private/Files/Cache/Propagator.php b/lib/private/Files/Cache/Propagator.php index 4bf88a60843..a6ba87896f4 100644 --- a/lib/private/Files/Cache/Propagator.php +++ b/lib/private/Files/Cache/Propagator.php @@ -1,35 +1,20 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Files\Cache; -use Doctrine\DBAL\Exception\RetryableException; +use OC\DB\Exceptions\DbalException; use OC\Files\Storage\Wrapper\Encryption; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Files\Cache\IPropagator; use OCP\Files\Storage\IReliableEtagStorage; use OCP\IDBConnection; +use OCP\Server; +use Psr\Clock\ClockInterface; use Psr\Log\LoggerInterface; /** @@ -56,13 +41,15 @@ class Propagator implements IPropagator { */ private $ignore = []; + private ClockInterface $clock; + public function __construct(\OC\Files\Storage\Storage $storage, IDBConnection $connection, array $ignore = []) { $this->storage = $storage; $this->connection = $connection; $this->ignore = $ignore; + $this->clock = Server::get(ClockInterface::class); } - /** * @param string $internalPath * @param int $time @@ -71,12 +58,14 @@ class Propagator implements IPropagator { public function propagateChange($internalPath, $time, $sizeDifference = 0) { // Do not propagate changes in ignored paths foreach ($this->ignore as $ignore) { - if (strpos($internalPath, $ignore) === 0) { + if (str_starts_with($internalPath, $ignore)) { return; } } - $storageId = (int)$this->storage->getStorageCache()->getNumericId(); + $time = min((int)$time, $this->clock->now()->getTimestamp()); + + $storageId = $this->storage->getStorageCache()->getNumericId(); $parents = $this->getParents($internalPath); @@ -96,7 +85,7 @@ class Propagator implements IPropagator { }, $parentHashes); $builder->update('filecache') - ->set('mtime', $builder->func()->greatest('mtime', $builder->createNamedParameter((int)$time, IQueryBuilder::PARAM_INT))) + ->set('mtime', $builder->func()->greatest('mtime', $builder->createNamedParameter($time, IQueryBuilder::PARAM_INT))) ->where($builder->expr()->eq('storage', $builder->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))) ->andWhere($builder->expr()->in('path_hash', $hashParams)); if (!$this->storage->instanceOfStorage(IReliableEtagStorage::class)) { @@ -136,7 +125,11 @@ class Propagator implements IPropagator { try { $builder->executeStatement(); break; - } catch (RetryableException $e) { + } catch (DbalException $e) { + if (!$e->isRetryable()) { + throw $e; + } + /** @var LoggerInterface $loggerInterface */ $loggerInterface = \OCP\Server::get(LoggerInterface::class); $loggerInterface->warning('Retrying propagation query after retryable exception.', [ 'exception' => $e ]); @@ -199,27 +192,27 @@ class Propagator implements IPropagator { $query->update('filecache') ->set('mtime', $query->func()->greatest('mtime', $query->createParameter('time'))) ->set('etag', $query->expr()->literal(uniqid())) - ->where($query->expr()->eq('storage', $query->expr()->literal($storageId, IQueryBuilder::PARAM_INT))) + ->where($query->expr()->eq('storage', $query->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))) ->andWhere($query->expr()->eq('path_hash', $query->createParameter('hash'))); $sizeQuery = $this->connection->getQueryBuilder(); $sizeQuery->update('filecache') ->set('size', $sizeQuery->func()->add('size', $sizeQuery->createParameter('size'))) - ->where($query->expr()->eq('storage', $query->expr()->literal($storageId, IQueryBuilder::PARAM_INT))) - ->andWhere($query->expr()->eq('path_hash', $query->createParameter('hash'))) - ->andWhere($sizeQuery->expr()->gt('size', $sizeQuery->expr()->literal(-1, IQueryBuilder::PARAM_INT))); + ->where($query->expr()->eq('storage', $sizeQuery->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq('path_hash', $sizeQuery->createParameter('hash'))) + ->andWhere($sizeQuery->expr()->gt('size', $sizeQuery->createNamedParameter(-1, IQueryBuilder::PARAM_INT))); foreach ($this->batch as $item) { $query->setParameter('time', $item['time'], IQueryBuilder::PARAM_INT); $query->setParameter('hash', $item['hash']); - $query->execute(); + $query->executeStatement(); if ($item['size']) { $sizeQuery->setParameter('size', $item['size'], IQueryBuilder::PARAM_INT); $sizeQuery->setParameter('hash', $item['hash']); - $sizeQuery->execute(); + $sizeQuery->executeStatement(); } } diff --git a/lib/private/Files/Cache/QuerySearchHelper.php b/lib/private/Files/Cache/QuerySearchHelper.php index eba2aac927b..3ddcf1ca4e6 100644 --- a/lib/private/Files/Cache/QuerySearchHelper.php +++ b/lib/private/Files/Cache/QuerySearchHelper.php @@ -1,30 +1,12 @@ <?php + /** - * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Tobias Kaminsky <tobias@kaminsky.me> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Cache; +use OC\Files\Cache\Wrapper\CacheJail; use OC\Files\Search\QueryOptimizer\QueryOptimizer; use OC\Files\Search\SearchBinaryOperator; use OC\SystemConfig; @@ -32,49 +14,115 @@ use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Files\Cache\ICache; use OCP\Files\Cache\ICacheEntry; use OCP\Files\IMimeTypeLoader; +use OCP\Files\IRootFolder; +use OCP\Files\Mount\IMountPoint; use OCP\Files\Search\ISearchBinaryOperator; use OCP\Files\Search\ISearchQuery; +use OCP\FilesMetadata\IFilesMetadataManager; +use OCP\FilesMetadata\IMetadataQuery; use OCP\IDBConnection; +use OCP\IGroupManager; +use OCP\IUser; use Psr\Log\LoggerInterface; class QuerySearchHelper { - /** @var IMimeTypeLoader */ - private $mimetypeLoader; - /** @var IDBConnection */ - private $connection; - /** @var SystemConfig */ - private $systemConfig; - private LoggerInterface $logger; - /** @var SearchBuilder */ - private $searchBuilder; - /** @var QueryOptimizer */ - private $queryOptimizer; - public function __construct( - IMimeTypeLoader $mimetypeLoader, - IDBConnection $connection, - SystemConfig $systemConfig, - LoggerInterface $logger, - SearchBuilder $searchBuilder, - QueryOptimizer $queryOptimizer + private IMimeTypeLoader $mimetypeLoader, + private IDBConnection $connection, + private SystemConfig $systemConfig, + private LoggerInterface $logger, + private SearchBuilder $searchBuilder, + private QueryOptimizer $queryOptimizer, + private IGroupManager $groupManager, + private IFilesMetadataManager $filesMetadataManager, ) { - $this->mimetypeLoader = $mimetypeLoader; - $this->connection = $connection; - $this->systemConfig = $systemConfig; - $this->logger = $logger; - $this->searchBuilder = $searchBuilder; - $this->queryOptimizer = $queryOptimizer; } protected function getQueryBuilder() { return new CacheQueryBuilder( - $this->connection, - $this->systemConfig, - $this->logger + $this->connection->getQueryBuilder(), + $this->filesMetadataManager, ); } /** + * @param CacheQueryBuilder $query + * @param ISearchQuery $searchQuery + * @param array $caches + * @param IMetadataQuery|null $metadataQuery + */ + protected function applySearchConstraints( + CacheQueryBuilder $query, + ISearchQuery $searchQuery, + array $caches, + ?IMetadataQuery $metadataQuery = null, + ): void { + $storageFilters = array_values(array_map(function (ICache $cache) { + return $cache->getQueryFilterForStorage(); + }, $caches)); + $storageFilter = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, $storageFilters); + $filter = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [$searchQuery->getSearchOperation(), $storageFilter]); + $this->queryOptimizer->processOperator($filter); + + $searchExpr = $this->searchBuilder->searchOperatorToDBExpr($query, $filter, $metadataQuery); + if ($searchExpr) { + $query->andWhere($searchExpr); + } + + $this->searchBuilder->addSearchOrdersToQuery($query, $searchQuery->getOrder(), $metadataQuery); + + if ($searchQuery->getLimit()) { + $query->setMaxResults($searchQuery->getLimit()); + } + if ($searchQuery->getOffset()) { + $query->setFirstResult($searchQuery->getOffset()); + } + } + + + /** + * @return array<array-key, array{id: int, name: string, visibility: int, editable: int, ref_file_id: int, number_files: int}> + */ + public function findUsedTagsInCaches(ISearchQuery $searchQuery, array $caches): array { + $query = $this->getQueryBuilder(); + $query->selectTagUsage(); + + $this->applySearchConstraints($query, $searchQuery, $caches); + + $result = $query->executeQuery(); + $tags = $result->fetchAll(); + $result->closeCursor(); + return $tags; + } + + protected function equipQueryForSystemTags(CacheQueryBuilder $query, IUser $user): void { + $query->leftJoin('file', 'systemtag_object_mapping', 'systemtagmap', $query->expr()->andX( + $query->expr()->eq('file.fileid', $query->expr()->castColumn('systemtagmap.objectid', IQueryBuilder::PARAM_INT)), + $query->expr()->eq('systemtagmap.objecttype', $query->createNamedParameter('files')) + )); + $on = $query->expr()->andX($query->expr()->eq('systemtag.id', 'systemtagmap.systemtagid')); + if (!$this->groupManager->isAdmin($user->getUID())) { + $on->add($query->expr()->eq('systemtag.visibility', $query->createNamedParameter(true))); + } + $query->leftJoin('systemtagmap', 'systemtag', 'systemtag', $on); + } + + protected function equipQueryForDavTags(CacheQueryBuilder $query, IUser $user): void { + $query + ->leftJoin('file', 'vcategory_to_object', 'tagmap', $query->expr()->eq('file.fileid', 'tagmap.objid')) + ->leftJoin('tagmap', 'vcategory', 'tag', $query->expr()->andX( + $query->expr()->eq('tagmap.categoryid', 'tag.id'), + $query->expr()->eq('tag.type', $query->createNamedParameter('files')), + $query->expr()->eq('tag.uid', $query->createNamedParameter($user->getUID())) + )); + } + + + protected function equipQueryForShares(CacheQueryBuilder $query): void { + $query->join('file', 'share', 's', $query->expr()->eq('file.fileid', 's.file_source')); + } + + /** * Perform a file system search in multiple caches * * the results will be grouped by the same array keys as the $caches argument to allow @@ -104,61 +152,34 @@ class QuerySearchHelper { $query = $builder->selectFileCache('file', false); - if ($this->searchBuilder->shouldJoinTags($searchQuery->getSearchOperation())) { - $user = $searchQuery->getUser(); - if ($user === null) { - throw new \InvalidArgumentException("Searching by tag requires the user to be set in the query"); - } - $query - ->leftJoin('file', 'vcategory_to_object', 'tagmap', $builder->expr()->eq('file.fileid', 'tagmap.objid')) - ->leftJoin('tagmap', 'vcategory', 'tag', $builder->expr()->andX( - $builder->expr()->eq('tagmap.type', 'tag.type'), - $builder->expr()->eq('tagmap.categoryid', 'tag.id'), - $builder->expr()->eq('tag.type', $builder->createNamedParameter('files')), - $builder->expr()->eq('tag.uid', $builder->createNamedParameter($user->getUID())) - )) - ->leftJoin('file', 'systemtag_object_mapping', 'systemtagmap', $builder->expr()->andX( - $builder->expr()->eq('file.fileid', $builder->expr()->castColumn('systemtagmap.objectid', IQueryBuilder::PARAM_INT)), - $builder->expr()->eq('systemtagmap.objecttype', $builder->createNamedParameter('files')) - )) - ->leftJoin('systemtagmap', 'systemtag', 'systemtag', $builder->expr()->andX( - $builder->expr()->eq('systemtag.id', 'systemtagmap.systemtagid'), - $builder->expr()->eq('systemtag.visibility', $builder->createNamedParameter(true)) - )); - } - - $storageFilters = array_values(array_map(function (ICache $cache) { - return $cache->getQueryFilterForStorage(); - }, $caches)); - $storageFilter = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, $storageFilters); - $filter = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_AND, [$searchQuery->getSearchOperation(), $storageFilter]); - $this->queryOptimizer->processOperator($filter); + $requestedFields = $this->searchBuilder->extractRequestedFields($searchQuery->getSearchOperation()); - $searchExpr = $this->searchBuilder->searchOperatorToDBExpr($builder, $filter); - if ($searchExpr) { - $query->andWhere($searchExpr); + if (in_array('systemtag', $requestedFields)) { + $this->equipQueryForSystemTags($query, $this->requireUser($searchQuery)); } - - $this->searchBuilder->addSearchOrdersToQuery($query, $searchQuery->getOrder()); - - if ($searchQuery->getLimit()) { - $query->setMaxResults($searchQuery->getLimit()); + if (in_array('tagname', $requestedFields) || in_array('favorite', $requestedFields)) { + $this->equipQueryForDavTags($query, $this->requireUser($searchQuery)); } - if ($searchQuery->getOffset()) { - $query->setFirstResult($searchQuery->getOffset()); + if (in_array('owner', $requestedFields) || in_array('share_with', $requestedFields) || in_array('share_type', $requestedFields)) { + $this->equipQueryForShares($query); } - $result = $query->execute(); + $metadataQuery = $query->selectMetadata(); + + $this->applySearchConstraints($query, $searchQuery, $caches, $metadataQuery); + + $result = $query->executeQuery(); $files = $result->fetchAll(); - $rawEntries = array_map(function (array $data) { + $rawEntries = array_map(function (array $data) use ($metadataQuery) { + $data['metadata'] = $metadataQuery->extractMetadata($data)->asArray(); return Cache::cacheEntryFromData($data, $this->mimetypeLoader); }, $files); $result->closeCursor(); // loop through all caches for each result to see if the result matches that storage - // results are grouped by the same array keys as the caches argument to allow the caller to distringuish the source of the results + // results are grouped by the same array keys as the caches argument to allow the caller to distinguish the source of the results $results = array_fill_keys(array_keys($caches), []); foreach ($rawEntries as $rawEntry) { foreach ($caches as $cacheKey => $cache) { @@ -170,4 +191,50 @@ class QuerySearchHelper { } return $results; } + + protected function requireUser(ISearchQuery $searchQuery): IUser { + $user = $searchQuery->getUser(); + if ($user === null) { + throw new \InvalidArgumentException('This search operation requires the user to be set in the query'); + } + return $user; + } + + /** + * @return list{0?: array<array-key, ICache>, 1?: array<array-key, IMountPoint>} + */ + public function getCachesAndMountPointsForSearch(IRootFolder $root, string $path, bool $limitToHome = false): array { + $rootLength = strlen($path); + $mount = $root->getMount($path); + $storage = $mount->getStorage(); + if ($storage === null) { + return []; + } + $internalPath = $mount->getInternalPath($path); + + if ($internalPath !== '') { + // a temporary CacheJail is used to handle filtering down the results to within this folder + /** @var ICache[] $caches */ + $caches = ['' => new CacheJail($storage->getCache(''), $internalPath)]; + } else { + /** @var ICache[] $caches */ + $caches = ['' => $storage->getCache('')]; + } + /** @var IMountPoint[] $mountByMountPoint */ + $mountByMountPoint = ['' => $mount]; + + if (!$limitToHome) { + $mounts = $root->getMountsIn($path); + foreach ($mounts as $mount) { + $storage = $mount->getStorage(); + if ($storage) { + $relativeMountPoint = ltrim(substr($mount->getMountPoint(), $rootLength), '/'); + $caches[$relativeMountPoint] = $storage->getCache(''); + $mountByMountPoint[$relativeMountPoint] = $mount; + } + } + } + + return [$caches, $mountByMountPoint]; + } } diff --git a/lib/private/Files/Cache/Scanner.php b/lib/private/Files/Cache/Scanner.php index f7d1d105d83..b067f70b8cb 100644 --- a/lib/private/Files/Cache/Scanner.php +++ b/lib/private/Files/Cache/Scanner.php @@ -1,48 +1,24 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Ari Selseng <ari@selseng.net> - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Jagszent <daniel@jagszent.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Martin Mattel <martin.mattel@diemattels.at> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Owen Winkler <a_github@midnightcircus.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Cache; use Doctrine\DBAL\Exception; +use OC\Files\Storage\Wrapper\Encryption; +use OC\Files\Storage\Wrapper\Jail; +use OC\Hooks\BasicEmitter; +use OC\SystemConfig; use OCP\Files\Cache\IScanner; use OCP\Files\ForbiddenException; +use OCP\Files\NotFoundException; +use OCP\Files\Storage\ILockingStorage; use OCP\Files\Storage\IReliableEtagStorage; +use OCP\IDBConnection; use OCP\Lock\ILockingProvider; -use OC\Files\Storage\Wrapper\Encoding; -use OC\Files\Storage\Wrapper\Jail; -use OC\Hooks\BasicEmitter; use Psr\Log\LoggerInterface; /** @@ -87,12 +63,18 @@ class Scanner extends BasicEmitter implements IScanner { */ protected $lockingProvider; + protected IDBConnection $connection; + public function __construct(\OC\Files\Storage\Storage $storage) { $this->storage = $storage; $this->storageId = $this->storage->getId(); $this->cache = $storage->getCache(); - $this->cacheActive = !\OC::$server->getConfig()->getSystemValue('filesystem_cache_readonly', false); - $this->lockingProvider = \OC::$server->getLockingProvider(); + /** @var SystemConfig $config */ + $config = \OC::$server->get(SystemConfig::class); + $this->cacheActive = !$config->getValue('filesystem_cache_readonly', false); + $this->useTransactions = !$config->getValue('filescanner_no_transactions', false); + $this->lockingProvider = \OC::$server->get(ILockingProvider::class); + $this->connection = \OC::$server->get(IDBConnection::class); } /** @@ -101,7 +83,7 @@ class Scanner extends BasicEmitter implements IScanner { * * @param bool $useTransactions */ - public function setUseTransactions($useTransactions) { + public function setUseTransactions($useTransactions): void { $this->useTransactions = $useTransactions; } @@ -126,9 +108,9 @@ class Scanner extends BasicEmitter implements IScanner { * @param string $file * @param int $reuseExisting * @param int $parentId - * @param array|null|false $cacheData existing data in the cache for the file to be scanned + * @param array|CacheEntry|null|false $cacheData existing data in the cache for the file to be scanned * @param bool $lock set to false to disable getting an additional read lock during scanning - * @param null $data the metadata for the file, as returned by the storage + * @param array|null $data the metadata for the file, as returned by the storage * @return array|null an array of metadata of the scanned file * @throws \OCP\Lock\LockedException */ @@ -140,123 +122,130 @@ class Scanner extends BasicEmitter implements IScanner { return null; } } + // only proceed if $file is not a partial file, blacklist is handled by the storage - if (!self::isPartialFile($file)) { - // acquire a lock + if (self::isPartialFile($file)) { + return null; + } + + // acquire a lock + if ($lock) { + if ($this->storage->instanceOfStorage(ILockingStorage::class)) { + $this->storage->acquireLock($file, ILockingProvider::LOCK_SHARED, $this->lockingProvider); + } + } + + try { + $data = $data ?? $this->getData($file); + } catch (ForbiddenException $e) { if ($lock) { - if ($this->storage->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { - $this->storage->acquireLock($file, ILockingProvider::LOCK_SHARED, $this->lockingProvider); + if ($this->storage->instanceOfStorage(ILockingStorage::class)) { + $this->storage->releaseLock($file, ILockingProvider::LOCK_SHARED, $this->lockingProvider); } } - try { - $data = $data ?? $this->getData($file); - } catch (ForbiddenException $e) { - if ($lock) { - if ($this->storage->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { - $this->storage->releaseLock($file, ILockingProvider::LOCK_SHARED, $this->lockingProvider); - } + return null; + } + + try { + if ($data === null) { + $this->removeFromCache($file); + } else { + // pre-emit only if it was a file. By that we avoid counting/treating folders as files + if ($data['mimetype'] !== 'httpd/unix-directory') { + $this->emit('\OC\Files\Cache\Scanner', 'scanFile', [$file, $this->storageId]); + \OC_Hook::emit('\OC\Files\Cache\Scanner', 'scan_file', ['path' => $file, 'storage' => $this->storageId]); } - return null; - } + $parent = dirname($file); + if ($parent === '.' || $parent === '/') { + $parent = ''; + } + if ($parentId === -1) { + $parentId = $this->cache->getParentId($file); + } - try { - if ($data) { - // pre-emit only if it was a file. By that we avoid counting/treating folders as files - if ($data['mimetype'] !== 'httpd/unix-directory') { - $this->emit('\OC\Files\Cache\Scanner', 'scanFile', [$file, $this->storageId]); - \OC_Hook::emit('\OC\Files\Cache\Scanner', 'scan_file', ['path' => $file, 'storage' => $this->storageId]); + // scan the parent if it's not in the cache (id -1) and the current file is not the root folder + if ($file && $parentId === -1) { + $parentData = $this->scanFile($parent); + if ($parentData === null) { + return null; } - $parent = dirname($file); - if ($parent === '.' || $parent === '/') { - $parent = ''; - } - if ($parentId === -1) { - $parentId = $this->cache->getParentId($file); - } + $parentId = $parentData['fileid']; + } + if ($parent) { + $data['parent'] = $parentId; + } - // scan the parent if it's not in the cache (id -1) and the current file is not the root folder - if ($file && $parentId === -1) { - $parentData = $this->scanFile($parent); - if (!$parentData) { - return null; + $cacheData = $cacheData ?? $this->cache->get($file); + if ($reuseExisting && $cacheData !== false && isset($cacheData['fileid'])) { + // prevent empty etag + $etag = empty($cacheData['etag']) ? $data['etag'] : $cacheData['etag']; + $fileId = $cacheData['fileid']; + $data['fileid'] = $fileId; + // only reuse data if the file hasn't explicitly changed + $mtimeUnchanged = isset($data['storage_mtime']) && isset($cacheData['storage_mtime']) && $data['storage_mtime'] === $cacheData['storage_mtime']; + // if the folder is marked as unscanned, never reuse etags + if ($mtimeUnchanged && $cacheData['size'] !== -1) { + $data['mtime'] = $cacheData['mtime']; + if (($reuseExisting & self::REUSE_SIZE) && ($data['size'] === -1)) { + $data['size'] = $cacheData['size']; } - $parentId = $parentData['fileid']; - } - if ($parent) { - $data['parent'] = $parentId; - } - if (is_null($cacheData)) { - /** @var CacheEntry $cacheData */ - $cacheData = $this->cache->get($file); - } - if ($cacheData && $reuseExisting && isset($cacheData['fileid'])) { - // prevent empty etag - $etag = empty($cacheData['etag']) ? $data['etag'] : $cacheData['etag']; - $fileId = $cacheData['fileid']; - $data['fileid'] = $fileId; - // only reuse data if the file hasn't explicitly changed - if (isset($data['storage_mtime']) && isset($cacheData['storage_mtime']) && $data['storage_mtime'] === $cacheData['storage_mtime']) { - $data['mtime'] = $cacheData['mtime']; - if (($reuseExisting & self::REUSE_SIZE) && ($data['size'] === -1)) { - $data['size'] = $cacheData['size']; - } - if ($reuseExisting & self::REUSE_ETAG && !$this->storage->instanceOfStorage(IReliableEtagStorage::class)) { - $data['etag'] = $etag; - } + if ($reuseExisting & self::REUSE_ETAG && !$this->storage->instanceOfStorage(IReliableEtagStorage::class)) { + $data['etag'] = $etag; } - // Only update metadata that has changed - $newData = array_diff_assoc($data, $cacheData->getData()); - } else { - $newData = $data; - $fileId = -1; - } - if (!empty($newData)) { - // Reset the checksum if the data has changed - $newData['checksum'] = ''; - $newData['parent'] = $parentId; - $data['fileid'] = $this->addToCache($file, $newData, $fileId); } - - $data['oldSize'] = ($cacheData && isset($cacheData['size'])) ? $cacheData['size'] : 0; - if ($cacheData && isset($cacheData['encrypted'])) { - $data['encrypted'] = $cacheData['encrypted']; + // we only updated unencrypted_size if it's already set + if (isset($cacheData['unencrypted_size']) && $cacheData['unencrypted_size'] === 0) { + unset($data['unencrypted_size']); } - // post-emit only if it was a file. By that we avoid counting/treating folders as files - if ($data['mimetype'] !== 'httpd/unix-directory') { - $this->emit('\OC\Files\Cache\Scanner', 'postScanFile', [$file, $this->storageId]); - \OC_Hook::emit('\OC\Files\Cache\Scanner', 'post_scan_file', ['path' => $file, 'storage' => $this->storageId]); + /** + * Only update metadata that has changed. + * i.e. get all the values in $data that are not present in the cache already + * + * We need the OC implementation for usage of "getData" method below. + * @var \OC\Files\Cache\CacheEntry $cacheData + */ + $newData = $this->array_diff_assoc_multi($data, $cacheData->getData()); + + // make it known to the caller that etag has been changed and needs propagation + if (isset($newData['etag'])) { + $data['etag_changed'] = true; } } else { - $this->removeFromCache($file); + unset($data['unencrypted_size']); + $newData = $data; + $fileId = -1; } - } catch (\Exception $e) { - if ($lock) { - if ($this->storage->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { - $this->storage->releaseLock($file, ILockingProvider::LOCK_SHARED, $this->lockingProvider); - } + if (!empty($newData)) { + // Reset the checksum if the data has changed + $newData['checksum'] = ''; + $newData['parent'] = $parentId; + $data['fileid'] = $this->addToCache($file, $newData, $fileId); } - throw $e; - } - // release the acquired lock - if ($lock) { - if ($this->storage->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { - $this->storage->releaseLock($file, ILockingProvider::LOCK_SHARED, $this->lockingProvider); + if ($cacheData !== false) { + $data['oldSize'] = $cacheData['size'] ?? 0; + $data['encrypted'] = $cacheData['encrypted'] ?? false; } - } - if ($data && !isset($data['encrypted'])) { - $data['encrypted'] = false; + // post-emit only if it was a file. By that we avoid counting/treating folders as files + if ($data['mimetype'] !== 'httpd/unix-directory') { + $this->emit('\OC\Files\Cache\Scanner', 'postScanFile', [$file, $this->storageId]); + \OC_Hook::emit('\OC\Files\Cache\Scanner', 'post_scan_file', ['path' => $file, 'storage' => $this->storageId]); + } + } + } finally { + // release the acquired lock + if ($lock && $this->storage->instanceOfStorage(ILockingStorage::class)) { + $this->storage->releaseLock($file, ILockingProvider::LOCK_SHARED, $this->lockingProvider); } - return $data; } - return null; + return $data; } protected function removeFromCache($path) { @@ -278,7 +267,7 @@ class Scanner extends BasicEmitter implements IScanner { $data['permissions'] = $data['scan_permissions']; } \OC_Hook::emit('Scanner', 'addToCache', ['file' => $path, 'data' => $data]); - $this->emit('\OC\Files\Cache\Scanner', 'addToCache', [$path, $this->storageId, $data]); + $this->emit('\OC\Files\Cache\Scanner', 'addToCache', [$path, $this->storageId, $data, $fileId]); if ($this->cacheActive) { if ($fileId !== -1) { $this->cache->update($fileId, $data); @@ -321,36 +310,82 @@ class Scanner extends BasicEmitter implements IScanner { if ($reuse === -1) { $reuse = ($recursive === self::SCAN_SHALLOW) ? self::REUSE_ETAG | self::REUSE_SIZE : self::REUSE_ETAG; } - if ($lock) { - if ($this->storage->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { - $this->storage->acquireLock('scanner::' . $path, ILockingProvider::LOCK_EXCLUSIVE, $this->lockingProvider); - $this->storage->acquireLock($path, ILockingProvider::LOCK_SHARED, $this->lockingProvider); - } + + if ($lock && $this->storage->instanceOfStorage(ILockingStorage::class)) { + $this->storage->acquireLock('scanner::' . $path, ILockingProvider::LOCK_EXCLUSIVE, $this->lockingProvider); + $this->storage->acquireLock($path, ILockingProvider::LOCK_SHARED, $this->lockingProvider); } + try { - $data = $this->scanFile($path, $reuse, -1, null, $lock); - if ($data && $data['mimetype'] === 'httpd/unix-directory') { - $size = $this->scanChildren($path, $recursive, $reuse, $data['fileid'], $lock, $data); + $data = $this->scanFile($path, $reuse, -1, lock: $lock); + + if ($data !== null && $data['mimetype'] === 'httpd/unix-directory') { + $size = $this->scanChildren($path, $recursive, $reuse, $data['fileid'], $lock, $data['size']); $data['size'] = $size; } + } catch (NotFoundException $e) { + $this->removeFromCache($path); + return null; } finally { - if ($lock) { - if ($this->storage->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { - $this->storage->releaseLock($path, ILockingProvider::LOCK_SHARED, $this->lockingProvider); - $this->storage->releaseLock('scanner::' . $path, ILockingProvider::LOCK_EXCLUSIVE, $this->lockingProvider); - } + if ($lock && $this->storage->instanceOfStorage(ILockingStorage::class)) { + $this->storage->releaseLock($path, ILockingProvider::LOCK_SHARED, $this->lockingProvider); + $this->storage->releaseLock('scanner::' . $path, ILockingProvider::LOCK_EXCLUSIVE, $this->lockingProvider); } } return $data; } /** + * Compares $array1 against $array2 and returns all the values in $array1 that are not in $array2 + * Note this is a one-way check - i.e. we don't care about things that are in $array2 that aren't in $array1 + * + * Supports multi-dimensional arrays + * Also checks keys/indexes + * Comparisons are strict just like array_diff_assoc + * Order of keys/values does not matter + * + * @param array $array1 + * @param array $array2 + * @return array with the differences between $array1 and $array1 + * @throws \InvalidArgumentException if $array1 isn't an actual array + * + */ + protected function array_diff_assoc_multi(array $array1, array $array2) { + + $result = []; + + foreach ($array1 as $key => $value) { + + // if $array2 doesn't have the same key, that's a result + if (!array_key_exists($key, $array2)) { + $result[$key] = $value; + continue; + } + + // if $array2's value for the same key is different, that's a result + if ($array2[$key] !== $value && !is_array($value)) { + $result[$key] = $value; + continue; + } + + if (is_array($value)) { + $nestedDiff = $this->array_diff_assoc_multi($value, $array2[$key]); + if (!empty($nestedDiff)) { + $result[$key] = $nestedDiff; + continue; + } + } + } + return $result; + } + + /** * Get the children currently in the cache * * @param int $folderId - * @return array[] + * @return array<string, \OCP\Files\Cache\ICacheEntry> */ - protected function getExistingChildren($folderId) { + protected function getExistingChildren($folderId): array { $existingChildren = []; $children = $this->cache->getFolderContentsById($folderId); foreach ($children as $child) { @@ -363,41 +398,60 @@ class Scanner extends BasicEmitter implements IScanner { * scan all the files and folders in a folder * * @param string $path - * @param bool $recursive - * @param int $reuse + * @param bool|IScanner::SCAN_RECURSIVE_INCOMPLETE $recursive + * @param int $reuse a combination of self::REUSE_* * @param int $folderId id for the folder to be scanned * @param bool $lock set to false to disable getting an additional read lock during scanning - * @param array $data the data of the folder before (re)scanning the children - * @return int the size of the scanned folder or -1 if the size is unknown at this stage + * @param int|float $oldSize the size of the folder before (re)scanning the children + * @return int|float the size of the scanned folder or -1 if the size is unknown at this stage */ - protected function scanChildren($path, $recursive = self::SCAN_RECURSIVE, $reuse = -1, $folderId = null, $lock = true, array $data = []) { + protected function scanChildren(string $path, $recursive, int $reuse, int $folderId, bool $lock, int|float $oldSize, &$etagChanged = false) { if ($reuse === -1) { $reuse = ($recursive === self::SCAN_SHALLOW) ? self::REUSE_ETAG | self::REUSE_SIZE : self::REUSE_ETAG; } $this->emit('\OC\Files\Cache\Scanner', 'scanFolder', [$path, $this->storageId]); $size = 0; - if (!is_null($folderId)) { - $folderId = $this->cache->getId($path); - } - $childQueue = $this->handleChildren($path, $recursive, $reuse, $folderId, $lock, $size); + $childQueue = $this->handleChildren($path, $recursive, $reuse, $folderId, $lock, $size, $etagChanged); + + foreach ($childQueue as $child => [$childId, $childSize]) { + // "etag changed" propagates up, but not down, so we pass `false` to the children even if we already know that the etag of the current folder changed + $childEtagChanged = false; + $childSize = $this->scanChildren($child, $recursive, $reuse, $childId, $lock, $childSize, $childEtagChanged); + $etagChanged |= $childEtagChanged; - foreach ($childQueue as $child => $childId) { - $childSize = $this->scanChildren($child, $recursive, $reuse, $childId, $lock); if ($childSize === -1) { $size = -1; } elseif ($size !== -1) { $size += $childSize; } } - $oldSize = $data['size'] ?? null; - if ($this->cacheActive && $oldSize !== $size) { - $this->cache->update($folderId, ['size' => $size]); + + // for encrypted storages, we trigger a regular folder size calculation instead of using the calculated size + // to make sure we also updated the unencrypted-size where applicable + if ($this->storage->instanceOfStorage(Encryption::class)) { + $this->cache->calculateFolderSize($path); + } else { + if ($this->cacheActive) { + $updatedData = []; + if ($oldSize !== $size) { + $updatedData['size'] = $size; + } + if ($etagChanged) { + $updatedData['etag'] = uniqid(); + } + if ($updatedData) { + $this->cache->update($folderId, $updatedData); + } + } } $this->emit('\OC\Files\Cache\Scanner', 'postScanFolder', [$path, $this->storageId]); return $size; } - private function handleChildren($path, $recursive, $reuse, $folderId, $lock, &$size) { + /** + * @param bool|IScanner::SCAN_RECURSIVE_INCOMPLETE $recursive + */ + private function handleChildren(string $path, $recursive, int $reuse, int $folderId, bool $lock, int|float &$size, bool &$etagChanged): array { // we put this in it's own function so it cleans up the memory before we start recursing $existingChildren = $this->getExistingChildren($folderId); $newChildren = iterator_to_array($this->storage->getDirectoryContent($path)); @@ -408,14 +462,14 @@ class Scanner extends BasicEmitter implements IScanner { } if ($this->useTransactions) { - \OC::$server->getDatabaseConnection()->beginTransaction(); + $this->connection->beginTransaction(); } $exceptionOccurred = false; $childQueue = []; $newChildNames = []; foreach ($newChildren as $fileMeta) { - $permissions = isset($fileMeta['scan_permissions']) ? $fileMeta['scan_permissions'] : $fileMeta['permissions']; + $permissions = $fileMeta['scan_permissions'] ?? $fileMeta['permissions']; if ($permissions === 0) { continue; } @@ -423,7 +477,7 @@ class Scanner extends BasicEmitter implements IScanner { $file = trim(\OC\Files\Filesystem::normalizePath($originalFile), '/'); if (trim($originalFile, '/') !== $file) { // encoding mismatch, might require compatibility wrapper - \OC::$server->get(LoggerInterface::class)->debug('Scanner: Skipping non-normalized file name "'. $originalFile . '" in path "' . $path . '".', ['app' => 'core']); + \OC::$server->get(LoggerInterface::class)->debug('Scanner: Skipping non-normalized file name "' . $originalFile . '" in path "' . $path . '".', ['app' => 'core']); $this->emit('\OC\Files\Cache\Scanner', 'normalizedNameMismatch', [$path ? $path . '/' . $originalFile : $originalFile]); // skip this entry continue; @@ -432,27 +486,31 @@ class Scanner extends BasicEmitter implements IScanner { $newChildNames[] = $file; $child = $path ? $path . '/' . $file : $file; try { - $existingData = isset($existingChildren[$file]) ? $existingChildren[$file] : false; + $existingData = $existingChildren[$file] ?? false; $data = $this->scanFile($child, $reuse, $folderId, $existingData, $lock, $fileMeta); if ($data) { if ($data['mimetype'] === 'httpd/unix-directory' && $recursive === self::SCAN_RECURSIVE) { - $childQueue[$child] = $data['fileid']; + $childQueue[$child] = [$data['fileid'], $data['size']]; } elseif ($data['mimetype'] === 'httpd/unix-directory' && $recursive === self::SCAN_RECURSIVE_INCOMPLETE && $data['size'] === -1) { // only recurse into folders which aren't fully scanned - $childQueue[$child] = $data['fileid']; + $childQueue[$child] = [$data['fileid'], $data['size']]; } elseif ($data['size'] === -1) { $size = -1; } elseif ($size !== -1) { $size += $data['size']; } + + if (isset($data['etag_changed']) && $data['etag_changed']) { + $etagChanged = true; + } } } catch (Exception $ex) { // might happen if inserting duplicate while a scanning // process is running in parallel // log and ignore if ($this->useTransactions) { - \OC::$server->getDatabaseConnection()->rollback(); - \OC::$server->getDatabaseConnection()->beginTransaction(); + $this->connection->rollback(); + $this->connection->beginTransaction(); } \OC::$server->get(LoggerInterface::class)->debug('Exception while scanning file "' . $child . '"', [ 'app' => 'core', @@ -461,7 +519,7 @@ class Scanner extends BasicEmitter implements IScanner { $exceptionOccurred = true; } catch (\OCP\Lock\LockedException $e) { if ($this->useTransactions) { - \OC::$server->getDatabaseConnection()->rollback(); + $this->connection->rollback(); } throw $e; } @@ -472,7 +530,7 @@ class Scanner extends BasicEmitter implements IScanner { $this->removeFromCache($child); } if ($this->useTransactions) { - \OC::$server->getDatabaseConnection()->commit(); + $this->connection->commit(); } if ($exceptionOccurred) { // It might happen that the parallel scan process has already @@ -496,7 +554,7 @@ class Scanner extends BasicEmitter implements IScanner { if (pathinfo($file, PATHINFO_EXTENSION) === 'part') { return true; } - if (strpos($file, '.part/') !== false) { + if (str_contains($file, '.part/')) { return true; } @@ -536,7 +594,7 @@ class Scanner extends BasicEmitter implements IScanner { } } - private function runBackgroundScanJob(callable $callback, $path) { + protected function runBackgroundScanJob(callable $callback, $path) { try { $callback(); \OC_Hook::emit('Scanner', 'correctFolderSize', ['path' => $path]); diff --git a/lib/private/Files/Cache/SearchBuilder.php b/lib/private/Files/Cache/SearchBuilder.php index 63dc4b9cd0e..e1d3c42a8a2 100644 --- a/lib/private/Files/Cache/SearchBuilder.php +++ b/lib/private/Files/Cache/SearchBuilder.php @@ -1,27 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Tobias Kaminsky <tobias@kaminsky.me> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Cache; @@ -32,11 +13,17 @@ use OCP\Files\Search\ISearchBinaryOperator; use OCP\Files\Search\ISearchComparison; use OCP\Files\Search\ISearchOperator; use OCP\Files\Search\ISearchOrder; +use OCP\FilesMetadata\IFilesMetadataManager; +use OCP\FilesMetadata\IMetadataQuery; /** * Tools for transforming search queries into database queries + * + * @psalm-import-type ParamSingleValue from ISearchComparison + * @psalm-import-type ParamValue from ISearchComparison */ class SearchBuilder { + /** @var array<string, string> */ protected static $searchOperatorMap = [ ISearchComparison::COMPARE_LIKE => 'iLike', ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE => 'like', @@ -45,8 +32,11 @@ class SearchBuilder { ISearchComparison::COMPARE_GREATER_THAN_EQUAL => 'gte', ISearchComparison::COMPARE_LESS_THAN => 'lt', ISearchComparison::COMPARE_LESS_THAN_EQUAL => 'lte', + ISearchComparison::COMPARE_DEFINED => 'isNotNull', + ISearchComparison::COMPARE_IN => 'in', ]; + /** @var array<string, string> */ protected static $searchOperatorNegativeMap = [ ISearchComparison::COMPARE_LIKE => 'notLike', ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE => 'notLike', @@ -55,47 +45,82 @@ class SearchBuilder { ISearchComparison::COMPARE_GREATER_THAN_EQUAL => 'lt', ISearchComparison::COMPARE_LESS_THAN => 'gte', ISearchComparison::COMPARE_LESS_THAN_EQUAL => 'gt', + ISearchComparison::COMPARE_DEFINED => 'isNull', + ISearchComparison::COMPARE_IN => 'notIn', ]; - public const TAG_FAVORITE = '_$!<Favorite>!$_'; + /** @var array<string, string> */ + protected static $fieldTypes = [ + 'mimetype' => 'string', + 'mtime' => 'integer', + 'name' => 'string', + 'path' => 'string', + 'size' => 'integer', + 'tagname' => 'string', + 'systemtag' => 'string', + 'favorite' => 'boolean', + 'fileid' => 'integer', + 'storage' => 'integer', + 'share_with' => 'string', + 'share_type' => 'integer', + 'owner' => 'string', + ]; - /** @var IMimeTypeLoader */ - private $mimetypeLoader; + /** @var array<string, int|string> */ + protected static $paramTypeMap = [ + 'string' => IQueryBuilder::PARAM_STR, + 'integer' => IQueryBuilder::PARAM_INT, + 'boolean' => IQueryBuilder::PARAM_BOOL, + ]; + + /** @var array<string, int> */ + protected static $paramArrayTypeMap = [ + 'string' => IQueryBuilder::PARAM_STR_ARRAY, + 'integer' => IQueryBuilder::PARAM_INT_ARRAY, + 'boolean' => IQueryBuilder::PARAM_INT_ARRAY, + ]; + + public const TAG_FAVORITE = '_$!<Favorite>!$_'; public function __construct( - IMimeTypeLoader $mimetypeLoader + private IMimeTypeLoader $mimetypeLoader, + private IFilesMetadataManager $filesMetadataManager, ) { - $this->mimetypeLoader = $mimetypeLoader; } /** - * Whether or not the tag tables should be joined to complete the search - * - * @param ISearchOperator $operator - * @return boolean + * @return string[] */ - public function shouldJoinTags(ISearchOperator $operator) { + public function extractRequestedFields(ISearchOperator $operator): array { if ($operator instanceof ISearchBinaryOperator) { - return array_reduce($operator->getArguments(), function ($shouldJoin, ISearchOperator $operator) { - return $shouldJoin || $this->shouldJoinTags($operator); - }, false); - } elseif ($operator instanceof ISearchComparison) { - return $operator->getField() === 'tagname' || $operator->getField() === 'favorite' || $operator->getField() === 'systemtag'; + return array_reduce($operator->getArguments(), function (array $fields, ISearchOperator $operator) { + return array_unique(array_merge($fields, $this->extractRequestedFields($operator))); + }, []); + } elseif ($operator instanceof ISearchComparison && !$operator->getExtra()) { + return [$operator->getField()]; } - return false; + return []; } /** * @param IQueryBuilder $builder * @param ISearchOperator[] $operators */ - public function searchOperatorArrayToDBExprArray(IQueryBuilder $builder, array $operators) { - return array_filter(array_map(function ($operator) use ($builder) { - return $this->searchOperatorToDBExpr($builder, $operator); + public function searchOperatorArrayToDBExprArray( + IQueryBuilder $builder, + array $operators, + ?IMetadataQuery $metadataQuery = null, + ) { + return array_filter(array_map(function ($operator) use ($builder, $metadataQuery) { + return $this->searchOperatorToDBExpr($builder, $operator, $metadataQuery); }, $operators)); } - public function searchOperatorToDBExpr(IQueryBuilder $builder, ISearchOperator $operator) { + public function searchOperatorToDBExpr( + IQueryBuilder $builder, + ISearchOperator $operator, + ?IMetadataQuery $metadataQuery = null, + ) { $expr = $builder->expr(); if ($operator instanceof ISearchBinaryOperator) { @@ -107,62 +132,99 @@ class SearchBuilder { case ISearchBinaryOperator::OPERATOR_NOT: $negativeOperator = $operator->getArguments()[0]; if ($negativeOperator instanceof ISearchComparison) { - return $this->searchComparisonToDBExpr($builder, $negativeOperator, self::$searchOperatorNegativeMap); + return $this->searchComparisonToDBExpr($builder, $negativeOperator, self::$searchOperatorNegativeMap, $metadataQuery); } else { throw new \InvalidArgumentException('Binary operators inside "not" is not supported'); } // no break case ISearchBinaryOperator::OPERATOR_AND: - return call_user_func_array([$expr, 'andX'], $this->searchOperatorArrayToDBExprArray($builder, $operator->getArguments())); + return call_user_func_array([$expr, 'andX'], $this->searchOperatorArrayToDBExprArray($builder, $operator->getArguments(), $metadataQuery)); case ISearchBinaryOperator::OPERATOR_OR: - return call_user_func_array([$expr, 'orX'], $this->searchOperatorArrayToDBExprArray($builder, $operator->getArguments())); + return call_user_func_array([$expr, 'orX'], $this->searchOperatorArrayToDBExprArray($builder, $operator->getArguments(), $metadataQuery)); default: throw new \InvalidArgumentException('Invalid operator type: ' . $operator->getType()); } } elseif ($operator instanceof ISearchComparison) { - return $this->searchComparisonToDBExpr($builder, $operator, self::$searchOperatorMap); + return $this->searchComparisonToDBExpr($builder, $operator, self::$searchOperatorMap, $metadataQuery); } else { throw new \InvalidArgumentException('Invalid operator type: ' . get_class($operator)); } } - private function searchComparisonToDBExpr(IQueryBuilder $builder, ISearchComparison $comparison, array $operatorMap) { - $this->validateComparison($comparison); + private function searchComparisonToDBExpr( + IQueryBuilder $builder, + ISearchComparison $comparison, + array $operatorMap, + ?IMetadataQuery $metadataQuery = null, + ) { + if ($comparison->getExtra()) { + [$field, $value, $type, $paramType] = $this->getExtraOperatorField($comparison, $metadataQuery); + } else { + [$field, $value, $type, $paramType] = $this->getOperatorFieldAndValue($comparison); + } - [$field, $value, $type] = $this->getOperatorFieldAndValue($comparison); if (isset($operatorMap[$type])) { $queryOperator = $operatorMap[$type]; - return $builder->expr()->$queryOperator($field, $this->getParameterForValue($builder, $value)); + return $builder->expr()->$queryOperator($field, $this->getParameterForValue($builder, $value, $paramType)); } else { throw new \InvalidArgumentException('Invalid operator type: ' . $comparison->getType()); } } - private function getOperatorFieldAndValue(ISearchComparison $operator) { + /** + * @param ISearchComparison $operator + * @return list{string, ParamValue, string, string} + */ + private function getOperatorFieldAndValue(ISearchComparison $operator): array { + $this->validateComparison($operator); $field = $operator->getField(); $value = $operator->getValue(); $type = $operator->getType(); + $pathEqHash = $operator->getQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, true); + return $this->getOperatorFieldAndValueInner($field, $value, $type, $pathEqHash); + } + + /** + * @param string $field + * @param ParamValue $value + * @param string $type + * @return list{string, ParamValue, string, string} + */ + private function getOperatorFieldAndValueInner(string $field, mixed $value, string $type, bool $pathEqHash): array { + $paramType = self::$fieldTypes[$field]; + if ($type === ISearchComparison::COMPARE_IN) { + $resultField = $field; + $values = []; + foreach ($value as $arrayValue) { + /** @var ParamSingleValue $arrayValue */ + [$arrayField, $arrayValue] = $this->getOperatorFieldAndValueInner($field, $arrayValue, ISearchComparison::COMPARE_EQUAL, $pathEqHash); + $resultField = $arrayField; + $values[] = $arrayValue; + } + return [$resultField, $values, ISearchComparison::COMPARE_IN, $paramType]; + } if ($field === 'mimetype') { $value = (string)$value; - if ($operator->getType() === ISearchComparison::COMPARE_EQUAL) { - $value = (int)$this->mimetypeLoader->getId($value); - } elseif ($operator->getType() === ISearchComparison::COMPARE_LIKE) { + if ($type === ISearchComparison::COMPARE_EQUAL) { + $value = $this->mimetypeLoader->getId($value); + } elseif ($type === ISearchComparison::COMPARE_LIKE) { // transform "mimetype='foo/%'" to "mimepart='foo'" if (preg_match('|(.+)/%|', $value, $matches)) { $field = 'mimepart'; - $value = (int)$this->mimetypeLoader->getId($matches[1]); + $value = $this->mimetypeLoader->getId($matches[1]); $type = ISearchComparison::COMPARE_EQUAL; - } elseif (strpos($value, '%') !== false) { + } elseif (str_contains($value, '%')) { throw new \InvalidArgumentException('Unsupported query value for mimetype: ' . $value . ', only values in the format "mime/type" or "mime/%" are supported'); } else { $field = 'mimetype'; - $value = (int)$this->mimetypeLoader->getId($value); + $value = $this->mimetypeLoader->getId($value); $type = ISearchComparison::COMPARE_EQUAL; } } } elseif ($field === 'favorite') { $field = 'tag.category'; $value = self::TAG_FAVORITE; + $paramType = 'string'; } elseif ($field === 'name') { $field = 'file.name'; } elseif ($field === 'tagname') { @@ -171,59 +233,89 @@ class SearchBuilder { $field = 'systemtag.name'; } elseif ($field === 'fileid') { $field = 'file.fileid'; - } elseif ($field === 'path' && $type === ISearchComparison::COMPARE_EQUAL && $operator->getQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, true)) { + } elseif ($field === 'path' && $type === ISearchComparison::COMPARE_EQUAL && $pathEqHash) { $field = 'path_hash'; $value = md5((string)$value); + } elseif ($field === 'owner') { + $field = 'uid_owner'; } - return [$field, $value, $type]; + return [$field, $value, $type, $paramType]; } private function validateComparison(ISearchComparison $operator) { - $types = [ - 'mimetype' => 'string', - 'mtime' => 'integer', - 'name' => 'string', - 'path' => 'string', - 'size' => 'integer', - 'tagname' => 'string', - 'systemtag' => 'string', - 'favorite' => 'boolean', - 'fileid' => 'integer', - 'storage' => 'integer', - ]; $comparisons = [ - 'mimetype' => ['eq', 'like'], + 'mimetype' => ['eq', 'like', 'in'], 'mtime' => ['eq', 'gt', 'lt', 'gte', 'lte'], - 'name' => ['eq', 'like', 'clike'], - 'path' => ['eq', 'like', 'clike'], + 'name' => ['eq', 'like', 'clike', 'in'], + 'path' => ['eq', 'like', 'clike', 'in'], 'size' => ['eq', 'gt', 'lt', 'gte', 'lte'], 'tagname' => ['eq', 'like'], 'systemtag' => ['eq', 'like'], 'favorite' => ['eq'], - 'fileid' => ['eq'], - 'storage' => ['eq'], + 'fileid' => ['eq', 'in'], + 'storage' => ['eq', 'in'], + 'share_with' => ['eq'], + 'share_type' => ['eq'], + 'owner' => ['eq'], ]; - if (!isset($types[$operator->getField()])) { + if (!isset(self::$fieldTypes[$operator->getField()])) { throw new \InvalidArgumentException('Unsupported comparison field ' . $operator->getField()); } - $type = $types[$operator->getField()]; - if (gettype($operator->getValue()) !== $type) { - throw new \InvalidArgumentException('Invalid type for field ' . $operator->getField()); + $type = self::$fieldTypes[$operator->getField()]; + if ($operator->getType() === ISearchComparison::COMPARE_IN) { + if (!is_array($operator->getValue())) { + throw new \InvalidArgumentException('Invalid type for field ' . $operator->getField()); + } + foreach ($operator->getValue() as $arrayValue) { + if (gettype($arrayValue) !== $type) { + throw new \InvalidArgumentException('Invalid type in array for field ' . $operator->getField()); + } + } + } else { + if (gettype($operator->getValue()) !== $type) { + throw new \InvalidArgumentException('Invalid type for field ' . $operator->getField()); + } } if (!in_array($operator->getType(), $comparisons[$operator->getField()])) { throw new \InvalidArgumentException('Unsupported comparison for field ' . $operator->getField() . ': ' . $operator->getType()); } } - private function getParameterForValue(IQueryBuilder $builder, $value) { + + private function getExtraOperatorField(ISearchComparison $operator, IMetadataQuery $metadataQuery): array { + $field = $operator->getField(); + $value = $operator->getValue(); + $type = $operator->getType(); + + $knownMetadata = $this->filesMetadataManager->getKnownMetadata(); + $isIndex = $knownMetadata->isIndex($field); + $paramType = $knownMetadata->getType($field) === 'int' ? 'integer' : 'string'; + + if (!$isIndex) { + throw new \InvalidArgumentException('Cannot search non indexed metadata key'); + } + + switch ($operator->getExtra()) { + case IMetadataQuery::EXTRA: + $metadataQuery->joinIndex($field); // join index table if not joined yet + $field = $metadataQuery->getMetadataValueField($field); + break; + default: + throw new \InvalidArgumentException('Invalid extra type: ' . $operator->getExtra()); + } + + return [$field, $value, $type, $paramType]; + } + + private function getParameterForValue(IQueryBuilder $builder, $value, string $paramType) { if ($value instanceof \DateTime) { $value = $value->getTimestamp(); } - if (is_numeric($value)) { - $type = IQueryBuilder::PARAM_INT; + if (is_array($value)) { + $type = self::$paramArrayTypeMap[$paramType]; } else { - $type = IQueryBuilder::PARAM_STR; + $type = self::$paramTypeMap[$paramType]; } return $builder->createNamedParameter($value, $type); } @@ -231,24 +323,32 @@ class SearchBuilder { /** * @param IQueryBuilder $query * @param ISearchOrder[] $orders + * @param IMetadataQuery|null $metadataQuery */ - public function addSearchOrdersToQuery(IQueryBuilder $query, array $orders) { + public function addSearchOrdersToQuery(IQueryBuilder $query, array $orders, ?IMetadataQuery $metadataQuery = null): void { foreach ($orders as $order) { $field = $order->getField(); - if ($field === 'fileid') { - $field = 'file.fileid'; - } + switch ($order->getExtra()) { + case IMetadataQuery::EXTRA: + $metadataQuery->joinIndex($field); // join index table if not joined yet + $field = $metadataQuery->getMetadataValueField($order->getField()); + break; - // Mysql really likes to pick an index for sorting if it can't fully satisfy the where - // filter with an index, since search queries pretty much never are fully filtered by index - // mysql often picks an index for sorting instead of the much more useful index for filtering. - // - // By changing the order by to an expression, mysql isn't smart enough to see that it could still - // use the index, so it instead picks an index for the filtering - if ($field === 'mtime') { - $field = $query->func()->add($field, $query->createNamedParameter(0)); - } + default: + if ($field === 'fileid') { + $field = 'file.fileid'; + } + // Mysql really likes to pick an index for sorting if it can't fully satisfy the where + // filter with an index, since search queries pretty much never are fully filtered by index + // mysql often picks an index for sorting instead of the much more useful index for filtering. + // + // By changing the order by to an expression, mysql isn't smart enough to see that it could still + // use the index, so it instead picks an index for the filtering + if ($field === 'mtime') { + $field = $query->func()->add($field, $query->createNamedParameter(0)); + } + } $query->addOrderBy($field, $order->getDirection()); } } diff --git a/lib/private/Files/Cache/Storage.php b/lib/private/Files/Cache/Storage.php index 01fc638cef8..1a3bda58e6a 100644 --- a/lib/private/Files/Cache/Storage.php +++ b/lib/private/Files/Cache/Storage.php @@ -1,36 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Cache; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\Files\Storage\IStorage; +use OCP\IDBConnection; use Psr\Log\LoggerInterface; /** @@ -65,7 +44,7 @@ class Storage { * @param bool $isAvailable * @throws \RuntimeException */ - public function __construct($storage, $isAvailable = true) { + public function __construct($storage, $isAvailable, IDBConnection $connection) { if ($storage instanceof IStorage) { $this->storageId = $storage->getId(); } else { @@ -76,7 +55,6 @@ class Storage { if ($row = self::getStorageById($this->storageId)) { $this->numericId = (int)$row['numeric_id']; } else { - $connection = \OC::$server->getDatabaseConnection(); $available = $isAvailable ? 1 : 0; if ($connection->insertIfNotExist('*PREFIX*storages', ['id' => $this->storageId, 'available' => $available])) { $this->numericId = $connection->lastInsertId('*PREFIX*storages'); @@ -102,7 +80,7 @@ class Storage { * Adjusts the storage id to use md5 if too long * @param string $storageId storage id * @return string unchanged $storageId if its length is less than 64 characters, - * else returns the md5 of $storageId + * else returns the md5 of $storageId */ public static function adjustStorageId($storageId) { if (strlen($storageId) > 64) { @@ -171,7 +149,7 @@ class Storage { public function setAvailability($isAvailable, int $delay = 0) { $available = $isAvailable ? 1 : 0; if (!$isAvailable) { - \OC::$server->get(LoggerInterface::class)->info('Storage with ' . $this->storageId . ' marked as unavailable', ['app' => 'lib']); + \OCP\Server::get(LoggerInterface::class)->info('Storage with ' . $this->storageId . ' marked as unavailable', ['app' => 'lib']); } $query = \OC::$server->getDatabaseConnection()->getQueryBuilder(); @@ -179,7 +157,7 @@ class Storage { ->set('available', $query->createNamedParameter($available)) ->set('last_checked', $query->createNamedParameter(time() + $delay)) ->where($query->expr()->eq('id', $query->createNamedParameter($this->storageId))); - $query->execute(); + $query->executeStatement(); } /** @@ -204,13 +182,13 @@ class Storage { $query = \OC::$server->getDatabaseConnection()->getQueryBuilder(); $query->delete('storages') ->where($query->expr()->eq('id', $query->createNamedParameter($storageId))); - $query->execute(); + $query->executeStatement(); if (!is_null($numericId)) { $query = \OC::$server->getDatabaseConnection()->getQueryBuilder(); $query->delete('filecache') ->where($query->expr()->eq('storage', $query->createNamedParameter($numericId))); - $query->execute(); + $query->executeStatement(); } } @@ -235,6 +213,7 @@ class Storage { $query = $db->getQueryBuilder(); $query->delete('filecache') ->where($query->expr()->in('storage', $query->createNamedParameter($storageIds, IQueryBuilder::PARAM_INT_ARRAY))); + $query->runAcrossAllShards(); $query->executeStatement(); $query = $db->getQueryBuilder(); diff --git a/lib/private/Files/Cache/StorageGlobal.php b/lib/private/Files/Cache/StorageGlobal.php index 74cbd5abdb2..bab31b1db91 100644 --- a/lib/private/Files/Cache/StorageGlobal.php +++ b/lib/private/Files/Cache/StorageGlobal.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Robin Appelman <robin@icewind.nl> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Cache; @@ -38,16 +21,14 @@ use OCP\IDBConnection; * @package OC\Files\Cache */ class StorageGlobal { - /** @var IDBConnection */ - private $connection; - /** @var array<string, array> */ private $cache = []; /** @var array<int, array> */ private $numericIdCache = []; - public function __construct(IDBConnection $connection) { - $this->connection = $connection; + public function __construct( + private IDBConnection $connection, + ) { } /** @@ -59,7 +40,7 @@ class StorageGlobal { ->from('storages') ->where($builder->expr()->in('id', $builder->createNamedParameter(array_values($storageIds), IQueryBuilder::PARAM_STR_ARRAY))); - $result = $query->execute(); + $result = $query->executeQuery(); while ($row = $result->fetch()) { $this->cache[$row['id']] = $row; } @@ -77,7 +58,7 @@ class StorageGlobal { ->from('storages') ->where($builder->expr()->eq('id', $builder->createNamedParameter($storageId))); - $result = $query->execute(); + $result = $query->executeQuery(); $row = $result->fetch(); $result->closeCursor(); @@ -100,7 +81,7 @@ class StorageGlobal { ->from('storages') ->where($builder->expr()->eq('numeric_id', $builder->createNamedParameter($numericId))); - $result = $query->execute(); + $result = $query->executeQuery(); $row = $result->fetch(); $result->closeCursor(); diff --git a/lib/private/Files/Cache/Updater.php b/lib/private/Files/Cache/Updater.php index f8c187996e6..03681036aa2 100644 --- a/lib/private/Files/Cache/Updater.php +++ b/lib/private/Files/Cache/Updater.php @@ -1,36 +1,20 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Jagszent <daniel@jagszent.de> - * @author Michael Gapczynski <GapczynskiM@gmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Cache; +use Doctrine\DBAL\Exception\DeadlockException; use OC\Files\FileInfo; +use OC\Files\ObjectStore\ObjectStoreStorage; +use OCP\Files\Cache\ICache; use OCP\Files\Cache\ICacheEntry; use OCP\Files\Cache\IUpdater; use OCP\Files\Storage\IStorage; +use Psr\Log\LoggerInterface; /** * Update the cache and propagate changes @@ -62,6 +46,8 @@ class Updater implements IUpdater { */ protected $cache; + private LoggerInterface $logger; + /** * @param \OC\Files\Storage\Storage $storage */ @@ -70,6 +56,7 @@ class Updater implements IUpdater { $this->propagator = $storage->getPropagator(); $this->scanner = $storage->getScanner(); $this->cache = $storage->getCache(); + $this->logger = \OC::$server->get(LoggerInterface::class); } /** @@ -114,7 +101,7 @@ class Updater implements IUpdater { * @param string $path * @param int $time */ - public function update($path, $time = null) { + public function update($path, $time = null, ?int $sizeDifference = null) { if (!$this->enabled or Scanner::isPartialFile($path)) { return; } @@ -123,20 +110,22 @@ class Updater implements IUpdater { } $data = $this->scanner->scan($path, Scanner::SCAN_SHALLOW, -1, false); - if ( - isset($data['oldSize']) && isset($data['size']) && - !$data['encrypted'] // encryption is a pita and touches the cache itself - ) { + + if (isset($data['oldSize']) && isset($data['size'])) { $sizeDifference = $data['size'] - $data['oldSize']; - } else { - // scanner didn't provide size info, fallback to full size calculation - $sizeDifference = 0; - if ($this->cache instanceof Cache) { - $this->cache->correctFolderSize($path, $data); - } + } + + // encryption is a pita and touches the cache itself + if (isset($data['encrypted']) && (bool)$data['encrypted']) { + $sizeDifference = null; + } + + // scanner didn't provide size info, fallback to full size calculation + if ($this->cache instanceof Cache && $sizeDifference === null) { + $this->cache->correctFolderSize($path, $data); } $this->correctParentStorageMtime($path); - $this->propagator->propagateChange($path, $time, $sizeDifference); + $this->propagator->propagateChange($path, $time, $sizeDifference ?? 0); } /** @@ -170,13 +159,51 @@ class Updater implements IUpdater { } /** - * Rename a file or folder in the cache and update the size, etag and mtime of the parent folders + * Rename a file or folder in the cache. * * @param IStorage $sourceStorage * @param string $source * @param string $target */ public function renameFromStorage(IStorage $sourceStorage, $source, $target) { + $this->copyOrRenameFromStorage($sourceStorage, $source, $target, function (ICache $sourceCache) use ($sourceStorage, $source, $target) { + // Remove existing cache entry to no reuse the fileId. + if ($this->cache->inCache($target)) { + $this->cache->remove($target); + } + + if ($sourceStorage === $this->storage) { + $this->cache->move($source, $target); + } else { + $this->cache->moveFromCache($sourceCache, $source, $target); + } + }); + } + + /** + * Copy a file or folder in the cache. + */ + public function copyFromStorage(IStorage $sourceStorage, string $source, string $target): void { + $this->copyOrRenameFromStorage($sourceStorage, $source, $target, function (ICache $sourceCache, ICacheEntry $sourceInfo) use ($target) { + $parent = dirname($target); + if ($parent === '.') { + $parent = ''; + } + $parentInCache = $this->cache->inCache($parent); + if (!$parentInCache) { + $parentData = $this->scanner->scan($parent, Scanner::SCAN_SHALLOW, -1, false); + $parentInCache = $parentData !== null; + } + if ($parentInCache) { + $this->cache->copyFromCache($sourceCache, $sourceInfo, $target); + } + }); + } + + /** + * Utility to copy or rename a file or folder in the cache and update the size, etag and mtime of the parent folders + */ + private function copyOrRenameFromStorage(IStorage $sourceStorage, string $source, string $target, callable $operation): void { if (!$this->enabled or Scanner::isPartialFile($source) or Scanner::isPartialFile($target)) { return; } @@ -189,27 +216,25 @@ class Updater implements IUpdater { $sourceInfo = $sourceCache->get($source); - if ($sourceInfo !== false) { - if ($this->cache->inCache($target)) { - $this->cache->remove($target); - } + $sourceExtension = pathinfo($source, PATHINFO_EXTENSION); + $targetExtension = pathinfo($target, PATHINFO_EXTENSION); + $targetIsTrash = preg_match("/^d\d+$/", $targetExtension); - if ($sourceStorage === $this->storage) { - $this->cache->move($source, $target); - } else { - $this->cache->moveFromCache($sourceCache, $source, $target); + if ($sourceInfo !== false) { + if (!$this->storage->instanceOfStorage(ObjectStoreStorage::class)) { + $operation($sourceCache, $sourceInfo); } - $sourceExtension = pathinfo($source, PATHINFO_EXTENSION); - $targetExtension = pathinfo($target, PATHINFO_EXTENSION); - $targetIsTrash = preg_match("/d\d+/", $targetExtension); + $isDir = $sourceInfo->getMimeType() === FileInfo::MIMETYPE_FOLDER; + } else { + $isDir = $this->storage->is_dir($target); + } - if ($sourceExtension !== $targetExtension && $sourceInfo->getMimeType() !== FileInfo::MIMETYPE_FOLDER && !$targetIsTrash) { - // handle mime type change - $mimeType = $this->storage->getMimeType($target); - $fileId = $this->cache->getId($target); - $this->cache->update($fileId, ['mimetype' => $mimeType]); - } + if ($sourceExtension !== $targetExtension && !$isDir && !$targetIsTrash) { + // handle mime type change + $mimeType = $this->storage->getMimeType($target); + $fileId = $this->cache->getId($target); + $this->cache->update($fileId, ['mimetype' => $mimeType]); } if ($sourceCache instanceof Cache) { @@ -253,7 +278,14 @@ class Updater implements IUpdater { if ($parentId != -1) { $mtime = $this->storage->filemtime($parent); if ($mtime !== false) { - $this->cache->update($parentId, ['storage_mtime' => $mtime]); + try { + $this->cache->update($parentId, ['storage_mtime' => $mtime]); + } catch (DeadlockException $e) { + // ignore the failure. + // with failures concurrent updates, someone else would have already done it. + // in the worst case the `storage_mtime` isn't updated, which should at most only trigger an extra rescan + $this->logger->warning('Error while updating parent storage_mtime, should be safe to ignore', ['exception' => $e]); + } } } } diff --git a/lib/private/Files/Cache/Watcher.php b/lib/private/Files/Cache/Watcher.php index acc76f263dc..f1de5d3cfb8 100644 --- a/lib/private/Files/Cache/Watcher.php +++ b/lib/private/Files/Cache/Watcher.php @@ -1,28 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Jagszent <daniel@jagszent.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Cache; @@ -52,6 +33,9 @@ class Watcher implements IWatcher { */ protected $scanner; + /** @var callable[] */ + protected $onUpdate = []; + /** * @param \OC\Files\Storage\Storage $storage */ @@ -119,6 +103,9 @@ class Watcher implements IWatcher { if ($this->cache instanceof Cache) { $this->cache->correctFolderSize($path); } + foreach ($this->onUpdate as $callback) { + $callback($path); + } } /** @@ -129,9 +116,9 @@ class Watcher implements IWatcher { * @return bool */ public function needsUpdate($path, $cachedData) { - if ($this->watchPolicy === self::CHECK_ALWAYS or ($this->watchPolicy === self::CHECK_ONCE and array_search($path, $this->checkedPaths) === false)) { + if ($this->watchPolicy === self::CHECK_ALWAYS or ($this->watchPolicy === self::CHECK_ONCE and !in_array($path, $this->checkedPaths))) { $this->checkedPaths[] = $path; - return $this->storage->hasUpdated($path, $cachedData['storage_mtime']); + return $cachedData['storage_mtime'] === null || $this->storage->hasUpdated($path, $cachedData['storage_mtime']); } return false; } @@ -149,4 +136,11 @@ class Watcher implements IWatcher { } } } + + /** + * register a callback to be called whenever the watcher triggers and update + */ + public function onUpdate(callable $callback): void { + $this->onUpdate[] = $callback; + } } diff --git a/lib/private/Files/Cache/Wrapper/CacheJail.php b/lib/private/Files/Cache/Wrapper/CacheJail.php index 628ca3ee0e0..5bc4ee8529d 100644 --- a/lib/private/Files/Cache/Wrapper/CacheJail.php +++ b/lib/private/Files/Cache/Wrapper/CacheJail.php @@ -1,35 +1,17 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Jagszent <daniel@jagszent.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Cache\Wrapper; use OC\Files\Cache\Cache; +use OC\Files\Cache\CacheDependencies; use OC\Files\Search\SearchBinaryOperator; use OC\Files\Search\SearchComparison; +use OCP\Files\Cache\ICache; use OCP\Files\Cache\ICacheEntry; use OCP\Files\Search\ISearchBinaryOperator; use OCP\Files\Search\ISearchComparison; @@ -39,29 +21,29 @@ use OCP\Files\Search\ISearchOperator; * Jail to a subdirectory of the wrapped cache */ class CacheJail extends CacheWrapper { - /** - * @var string - */ - protected $root; - protected $unjailedRoot; - /** - * @param ?\OCP\Files\Cache\ICache $cache - * @param string $root - */ - public function __construct($cache, $root) { - parent::__construct($cache); - $this->root = $root; - $this->connection = \OC::$server->getDatabaseConnection(); - $this->mimetypeLoader = \OC::$server->getMimeTypeLoader(); - - if ($cache instanceof CacheJail) { - $this->unjailedRoot = $cache->getSourcePath($root); - } else { - $this->unjailedRoot = $root; + protected string $unjailedRoot; + + public function __construct( + ?ICache $cache, + protected string $root, + ?CacheDependencies $dependencies = null, + ) { + parent::__construct($cache, $dependencies); + + $this->unjailedRoot = $root; + $parent = $cache; + while ($parent instanceof CacheWrapper) { + if ($parent instanceof CacheJail) { + $this->unjailedRoot = $parent->getSourcePath($this->unjailedRoot); + } + $parent = $parent->getCache(); } } + /** + * @return string + */ protected function getRoot() { return $this->root; } @@ -71,11 +53,14 @@ class CacheJail extends CacheWrapper { * * @return string */ - protected function getGetUnjailedRoot() { + public function getGetUnjailedRoot() { return $this->unjailedRoot; } - protected function getSourcePath($path) { + /** + * @return string + */ + protected function getSourcePath(string $path) { if ($path === '') { return $this->getRoot(); } else { @@ -88,7 +73,7 @@ class CacheJail extends CacheWrapper { * @param null|string $root * @return null|string the jailed path or null if the path is outside the jail */ - protected function getJailedPath(string $path, string $root = null) { + protected function getJailedPath(string $path, ?string $root = null) { if ($root === null) { $root = $this->getRoot(); } @@ -115,7 +100,7 @@ class CacheJail extends CacheWrapper { /** * get the stored metadata of a file or folder * - * @param string /int $file + * @param string|int $file * @return ICacheEntry|false */ public function get($file) { @@ -226,12 +211,12 @@ class CacheJail extends CacheWrapper { /** * update the folder size and the size of all parent folders * - * @param string|boolean $path - * @param array $data (optional) meta data of the folder + * @param array|ICacheEntry|null $data (optional) meta data of the folder */ - public function correctFolderSize($path, $data = null, $isBackgroundScan = false) { - if ($this->getCache() instanceof Cache) { - $this->getCache()->correctFolderSize($this->getSourcePath($path), $data, $isBackgroundScan); + public function correctFolderSize(string $path, $data = null, bool $isBackgroundScan = false): void { + $cache = $this->getCache(); + if ($cache instanceof Cache) { + $cache->correctFolderSize($this->getSourcePath($path), $data, $isBackgroundScan); } } @@ -239,12 +224,13 @@ class CacheJail extends CacheWrapper { * get the size of a folder and set it in the cache * * @param string $path - * @param array $entry (optional) meta data of the folder - * @return int + * @param array|null|ICacheEntry $entry (optional) meta data of the folder + * @return int|float */ public function calculateFolderSize($path, $entry = null) { - if ($this->getCache() instanceof Cache) { - return $this->getCache()->calculateFolderSize($this->getSourcePath($path), $entry); + $cache = $this->getCache(); + if ($cache instanceof Cache) { + return $cache->calculateFolderSize($this->getSourcePath($path), $entry); } else { return 0; } @@ -328,7 +314,7 @@ class CacheJail extends CacheWrapper { } public function getCacheEntryFromSearchResult(ICacheEntry $rawEntry): ?ICacheEntry { - if ($this->getGetUnjailedRoot() === '' || strpos($rawEntry->getPath(), $this->getGetUnjailedRoot()) === 0) { + if ($this->getGetUnjailedRoot() === '' || str_starts_with($rawEntry->getPath(), $this->getGetUnjailedRoot())) { $rawEntry = $this->getCache()->getCacheEntryFromSearchResult($rawEntry); if ($rawEntry) { $jailedPath = $this->getJailedPath($rawEntry->getPath()); diff --git a/lib/private/Files/Cache/Wrapper/CachePermissionsMask.php b/lib/private/Files/Cache/Wrapper/CachePermissionsMask.php index cbf16a909ff..ff17cb79ac7 100644 --- a/lib/private/Files/Cache/Wrapper/CachePermissionsMask.php +++ b/lib/private/Files/Cache/Wrapper/CachePermissionsMask.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Cache\Wrapper; diff --git a/lib/private/Files/Cache/Wrapper/CacheWrapper.php b/lib/private/Files/Cache/Wrapper/CacheWrapper.php index 66ae83fd144..f2f1036d6a3 100644 --- a/lib/private/Files/Cache/Wrapper/CacheWrapper.php +++ b/lib/private/Files/Cache/Wrapper/CacheWrapper.php @@ -1,61 +1,55 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Ari Selseng <ari@selseng.net> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Jagszent <daniel@jagszent.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Cache\Wrapper; use OC\Files\Cache\Cache; -use OC\Files\Cache\QuerySearchHelper; +use OC\Files\Cache\CacheDependencies; use OCP\Files\Cache\ICache; use OCP\Files\Cache\ICacheEntry; use OCP\Files\Search\ISearchOperator; use OCP\Files\Search\ISearchQuery; +use OCP\Server; class CacheWrapper extends Cache { /** - * @var \OCP\Files\Cache\ICache + * @var ?ICache */ protected $cache; - /** - * @param \OCP\Files\Cache\ICache $cache - */ - public function __construct($cache) { + public function __construct(?ICache $cache, ?CacheDependencies $dependencies = null) { $this->cache = $cache; - $this->mimetypeLoader = \OC::$server->getMimeTypeLoader(); - $this->connection = \OC::$server->getDatabaseConnection(); - $this->querySearchHelper = \OC::$server->get(QuerySearchHelper::class); + if (!$dependencies && $cache instanceof Cache) { + $this->mimetypeLoader = $cache->mimetypeLoader; + $this->connection = $cache->connection; + $this->querySearchHelper = $cache->querySearchHelper; + } else { + if (!$dependencies) { + $dependencies = Server::get(CacheDependencies::class); + } + $this->mimetypeLoader = $dependencies->getMimeTypeLoader(); + $this->connection = $dependencies->getConnection(); + $this->querySearchHelper = $dependencies->getQuerySearchHelper(); + } } protected function getCache() { return $this->cache; } + protected function hasEncryptionWrapper(): bool { + $cache = $this->getCache(); + if ($cache instanceof Cache) { + return $cache->hasEncryptionWrapper(); + } else { + return false; + } + } + /** * Make it easy for wrappers to modify every returned cache entry * @@ -74,7 +68,7 @@ class CacheWrapper extends Cache { */ public function get($file) { $result = $this->getCache()->get($file); - if ($result) { + if ($result instanceof ICacheEntry) { $result = $this->formatCacheEntry($result); } return $result; @@ -220,19 +214,19 @@ class CacheWrapper extends Cache { return $this->getCache()->getStatus($file); } - public function searchQuery(ISearchQuery $searchQuery) { - return current($this->querySearchHelper->searchInCaches($searchQuery, [$this])); + public function searchQuery(ISearchQuery $query) { + return current($this->querySearchHelper->searchInCaches($query, [$this])); } /** * update the folder size and the size of all parent folders * - * @param string|boolean $path - * @param array $data (optional) meta data of the folder + * @param array|ICacheEntry|null $data (optional) meta data of the folder */ - public function correctFolderSize($path, $data = null, $isBackgroundScan = false) { - if ($this->getCache() instanceof Cache) { - $this->getCache()->correctFolderSize($path, $data, $isBackgroundScan); + public function correctFolderSize(string $path, $data = null, bool $isBackgroundScan = false): void { + $cache = $this->getCache(); + if ($cache instanceof Cache) { + $cache->correctFolderSize($path, $data, $isBackgroundScan); } } @@ -240,12 +234,13 @@ class CacheWrapper extends Cache { * get the size of a folder and set it in the cache * * @param string $path - * @param array $entry (optional) meta data of the folder - * @return int + * @param array|null|ICacheEntry $entry (optional) meta data of the folder + * @return int|float */ public function calculateFolderSize($path, $entry = null) { - if ($this->getCache() instanceof Cache) { - return $this->getCache()->calculateFolderSize($path, $entry); + $cache = $this->getCache(); + if ($cache instanceof Cache) { + return $cache->calculateFolderSize($path, $entry); } else { return 0; } diff --git a/lib/private/Files/Cache/Wrapper/JailPropagator.php b/lib/private/Files/Cache/Wrapper/JailPropagator.php index 25e53ded39d..d6409b7875e 100644 --- a/lib/private/Files/Cache/Wrapper/JailPropagator.php +++ b/lib/private/Files/Cache/Wrapper/JailPropagator.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Cache\Wrapper; diff --git a/lib/private/Files/Cache/Wrapper/JailWatcher.php b/lib/private/Files/Cache/Wrapper/JailWatcher.php new file mode 100644 index 00000000000..b1ae516654a --- /dev/null +++ b/lib/private/Files/Cache/Wrapper/JailWatcher.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files\Cache\Wrapper; + +use OC\Files\Cache\Watcher; + +class JailWatcher extends Watcher { + private string $root; + private Watcher $watcher; + + public function __construct(Watcher $watcher, string $root) { + $this->watcher = $watcher; + $this->root = $root; + } + + protected function getRoot(): string { + return $this->root; + } + + protected function getSourcePath($path): string { + if ($path === '') { + return $this->getRoot(); + } else { + return $this->getRoot() . '/' . ltrim($path, '/'); + } + } + + public function setPolicy($policy) { + $this->watcher->setPolicy($policy); + } + + public function getPolicy() { + return $this->watcher->getPolicy(); + } + + + public function checkUpdate($path, $cachedEntry = null) { + return $this->watcher->checkUpdate($this->getSourcePath($path), $cachedEntry); + } + + public function update($path, $cachedData) { + $this->watcher->update($this->getSourcePath($path), $cachedData); + } + + public function needsUpdate($path, $cachedData) { + return $this->watcher->needsUpdate($this->getSourcePath($path), $cachedData); + } + + public function cleanFolder($path) { + $this->watcher->cleanFolder($this->getSourcePath($path)); + } + + public function onUpdate(callable $callback): void { + $this->watcher->onUpdate($callback); + } +} diff --git a/lib/private/Files/Config/CachedMountFileInfo.php b/lib/private/Files/Config/CachedMountFileInfo.php index 11a9a505808..69bd4e9301e 100644 --- a/lib/private/Files/Config/CachedMountFileInfo.php +++ b/lib/private/Files/Config/CachedMountFileInfo.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Config; @@ -37,7 +20,7 @@ class CachedMountFileInfo extends CachedMountInfo implements ICachedMountFileInf ?int $mountId, string $mountProvider, string $rootInternalPath, - string $internalPath + string $internalPath, ) { parent::__construct($user, $storageId, $rootId, $mountPoint, $mountProvider, $mountId, $rootInternalPath); $this->internalPath = $internalPath; diff --git a/lib/private/Files/Config/CachedMountInfo.php b/lib/private/Files/Config/CachedMountInfo.php index 43c9fae63ec..79dd6c6ea1d 100644 --- a/lib/private/Files/Config/CachedMountInfo.php +++ b/lib/private/Files/Config/CachedMountInfo.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Robin Appelman <robin@icewind.nl> - * @author Semih Serhat Karakaya <karakayasemi@itu.edu.tr> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Config; @@ -35,6 +20,7 @@ class CachedMountInfo implements ICachedMountInfo { protected ?int $mountId; protected string $rootInternalPath; protected string $mountProvider; + protected string $key; /** * CachedMountInfo constructor. @@ -52,8 +38,8 @@ class CachedMountInfo implements ICachedMountInfo { int $rootId, string $mountPoint, string $mountProvider, - int $mountId = null, - string $rootInternalPath = '' + ?int $mountId = null, + string $rootInternalPath = '', ) { $this->user = $user; $this->storageId = $storageId; @@ -65,6 +51,7 @@ class CachedMountInfo implements ICachedMountInfo { throw new \Exception("Mount provider $mountProvider name exceeds the limit of 128 characters"); } $this->mountProvider = $mountProvider; + $this->key = $rootId . '::' . $mountPoint; } /** @@ -95,12 +82,7 @@ class CachedMountInfo implements ICachedMountInfo { // TODO injection etc Filesystem::initMountPoints($this->getUser()->getUID()); $userNode = \OC::$server->getUserFolder($this->getUser()->getUID()); - $nodes = $userNode->getParent()->getById($this->getRootId()); - if (count($nodes) > 0) { - return $nodes[0]; - } else { - return null; - } + return $userNode->getParent()->getFirstNodeById($this->getRootId()); } /** @@ -132,4 +114,8 @@ class CachedMountInfo implements ICachedMountInfo { public function getMountProvider(): string { return $this->mountProvider; } + + public function getKey(): string { + return $this->key; + } } diff --git a/lib/private/Files/Config/LazyPathCachedMountInfo.php b/lib/private/Files/Config/LazyPathCachedMountInfo.php new file mode 100644 index 00000000000..d2396109b1a --- /dev/null +++ b/lib/private/Files/Config/LazyPathCachedMountInfo.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files\Config; + +use OCP\IUser; + +class LazyPathCachedMountInfo extends CachedMountInfo { + // we don't allow \ in paths so it makes a great placeholder + private const PATH_PLACEHOLDER = '\\PLACEHOLDER\\'; + + /** @var callable(CachedMountInfo): string */ + protected $rootInternalPathCallback; + + /** + * @param IUser $user + * @param int $storageId + * @param int $rootId + * @param string $mountPoint + * @param string $mountProvider + * @param int|null $mountId + * @param callable(CachedMountInfo): string $rootInternalPathCallback + * @throws \Exception + */ + public function __construct( + IUser $user, + int $storageId, + int $rootId, + string $mountPoint, + string $mountProvider, + ?int $mountId, + callable $rootInternalPathCallback, + ) { + parent::__construct($user, $storageId, $rootId, $mountPoint, $mountProvider, $mountId, self::PATH_PLACEHOLDER); + $this->rootInternalPathCallback = $rootInternalPathCallback; + } + + public function getRootInternalPath(): string { + if ($this->rootInternalPath === self::PATH_PLACEHOLDER) { + $this->rootInternalPath = ($this->rootInternalPathCallback)($this); + } + return $this->rootInternalPath; + } +} diff --git a/lib/private/Files/Config/LazyStorageMountInfo.php b/lib/private/Files/Config/LazyStorageMountInfo.php index 78055a2cdb8..eb2c60dfa46 100644 --- a/lib/private/Files/Config/LazyStorageMountInfo.php +++ b/lib/private/Files/Config/LazyStorageMountInfo.php @@ -1,23 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Config; @@ -39,6 +25,7 @@ class LazyStorageMountInfo extends CachedMountInfo { $this->rootId = 0; $this->storageId = 0; $this->mountPoint = ''; + $this->key = ''; } /** @@ -87,4 +74,11 @@ class LazyStorageMountInfo extends CachedMountInfo { public function getMountProvider(): string { return $this->mount->getMountProvider(); } + + public function getKey(): string { + if (!$this->key) { + $this->key = $this->getRootId() . '::' . $this->getMountPoint(); + } + return $this->key; + } } diff --git a/lib/private/Files/Config/MountProviderCollection.php b/lib/private/Files/Config/MountProviderCollection.php index 0e08d9d0e83..9d63184e05f 100644 --- a/lib/private/Files/Config/MountProviderCollection.php +++ b/lib/private/Files/Config/MountProviderCollection.php @@ -1,31 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Config; use OC\Hooks\Emitter; use OC\Hooks\EmitterTrait; +use OCP\Diagnostics\IEventLogger; use OCP\Files\Config\IHomeMountProvider; use OCP\Files\Config\IMountProvider; use OCP\Files\Config\IMountProviderCollection; @@ -40,63 +24,65 @@ class MountProviderCollection implements IMountProviderCollection, Emitter { use EmitterTrait; /** - * @var \OCP\Files\Config\IHomeMountProvider[] + * @var list<IHomeMountProvider> */ - private $homeProviders = []; + private array $homeProviders = []; /** - * @var \OCP\Files\Config\IMountProvider[] + * @var list<IMountProvider> */ - private $providers = []; + private array $providers = []; - /** @var \OCP\Files\Config\IRootMountProvider[] */ - private $rootProviders = []; + /** @var list<IRootMountProvider> */ + private array $rootProviders = []; - /** - * @var \OCP\Files\Storage\IStorageFactory - */ - private $loader; + /** @var list<callable> */ + private array $mountFilters = []; - /** - * @var \OCP\Files\Config\IUserMountCache - */ - private $mountCache; - - /** @var callable[] */ - private $mountFilters = []; + public function __construct( + private IStorageFactory $loader, + private IUserMountCache $mountCache, + private IEventLogger $eventLogger, + ) { + } /** - * @param \OCP\Files\Storage\IStorageFactory $loader - * @param IUserMountCache $mountCache + * @return list<IMountPoint> */ - public function __construct(IStorageFactory $loader, IUserMountCache $mountCache) { - $this->loader = $loader; - $this->mountCache = $mountCache; + private function getMountsFromProvider(IMountProvider $provider, IUser $user, IStorageFactory $loader): array { + $class = str_replace('\\', '_', get_class($provider)); + $uid = $user->getUID(); + $this->eventLogger->start('fs:setup:provider:' . $class, "Getting mounts from $class for $uid"); + $mounts = $provider->getMountsForUser($user, $loader) ?? []; + $this->eventLogger->end('fs:setup:provider:' . $class); + return array_values($mounts); } /** - * @param IUser $user - * @param IMountProvider[] $providers - * @return IMountPoint[] + * @param list<IMountProvider> $providers + * @return list<IMountPoint> */ private function getUserMountsForProviders(IUser $user, array $providers): array { $loader = $this->loader; $mounts = array_map(function (IMountProvider $provider) use ($user, $loader) { - return $provider->getMountsForUser($user, $loader); + return $this->getMountsFromProvider($provider, $user, $loader); }, $providers); - $mounts = array_filter($mounts, function ($result) { - return is_array($result); - }); $mounts = array_reduce($mounts, function (array $mounts, array $providerMounts) { return array_merge($mounts, $providerMounts); }, []); return $this->filterMounts($user, $mounts); } + /** + * @return list<IMountPoint> + */ public function getMountsForUser(IUser $user): array { return $this->getUserMountsForProviders($user, $this->providers); } + /** + * @return list<IMountPoint> + */ public function getUserMountsForProviderClasses(IUser $user, array $mountProviderClasses): array { $providers = array_filter( $this->providers, @@ -105,7 +91,10 @@ class MountProviderCollection implements IMountProviderCollection, Emitter { return $this->getUserMountsForProviders($user, $providers); } - public function addMountForUser(IUser $user, IMountManager $mountManager, callable $providerFilter = null) { + /** + * @return list<IMountPoint> + */ + public function addMountForUser(IUser $user, IMountManager $mountManager, ?callable $providerFilter = null): array { // shared mount provider gets to go last since it needs to know existing files // to check for name collisions $firstMounts = []; @@ -121,37 +110,32 @@ class MountProviderCollection implements IMountProviderCollection, Emitter { return (get_class($provider) === 'OCA\Files_Sharing\MountProvider'); }); foreach ($firstProviders as $provider) { - $mounts = $provider->getMountsForUser($user, $this->loader); - if (is_array($mounts)) { - $firstMounts = array_merge($firstMounts, $mounts); - } + $mounts = $this->getMountsFromProvider($provider, $user, $this->loader); + $firstMounts = array_merge($firstMounts, $mounts); } $firstMounts = $this->filterMounts($user, $firstMounts); array_walk($firstMounts, [$mountManager, 'addMount']); $lateMounts = []; foreach ($lastProviders as $provider) { - $mounts = $provider->getMountsForUser($user, $this->loader); - if (is_array($mounts)) { - $lateMounts = array_merge($lateMounts, $mounts); - } + $mounts = $this->getMountsFromProvider($provider, $user, $this->loader); + $lateMounts = array_merge($lateMounts, $mounts); } $lateMounts = $this->filterMounts($user, $lateMounts); + $this->eventLogger->start('fs:setup:add-mounts', 'Add mounts to the filesystem'); array_walk($lateMounts, [$mountManager, 'addMount']); + $this->eventLogger->end('fs:setup:add-mounts'); - return array_merge($lateMounts, $firstMounts); + return array_values(array_merge($lateMounts, $firstMounts)); } /** * Get the configured home mount for this user * - * @param \OCP\IUser $user - * @return \OCP\Files\Mount\IMountPoint * @since 9.1.0 */ - public function getHomeMountForUser(IUser $user) { - /** @var \OCP\Files\Config\IHomeMountProvider[] $providers */ + public function getHomeMountForUser(IUser $user): IMountPoint { $providers = array_reverse($this->homeProviders); // call the latest registered provider first to give apps an opportunity to overwrite builtin foreach ($providers as $homeProvider) { if ($mount = $homeProvider->getHomeMountForUser($user, $this->loader)) { @@ -164,34 +148,36 @@ class MountProviderCollection implements IMountProviderCollection, Emitter { /** * Add a provider for mount points - * - * @param \OCP\Files\Config\IMountProvider $provider */ - public function registerProvider(IMountProvider $provider) { + public function registerProvider(IMountProvider $provider): void { $this->providers[] = $provider; $this->emit('\OC\Files\Config', 'registerMountProvider', [$provider]); } - public function registerMountFilter(callable $filter) { + public function registerMountFilter(callable $filter): void { $this->mountFilters[] = $filter; } - private function filterMounts(IUser $user, array $mountPoints) { - return array_filter($mountPoints, function (IMountPoint $mountPoint) use ($user) { + /** + * @param list<IMountPoint> $mountPoints + * @return list<IMountPoint> + */ + private function filterMounts(IUser $user, array $mountPoints): array { + return array_values(array_filter($mountPoints, function (IMountPoint $mountPoint) use ($user) { foreach ($this->mountFilters as $filter) { if ($filter($mountPoint, $user) === false) { return false; } } return true; - }); + })); } /** * Add a provider for home mount points * - * @param \OCP\Files\Config\IHomeMountProvider $provider + * @param IHomeMountProvider $provider * @since 9.1.0 */ public function registerHomeProvider(IHomeMountProvider $provider) { @@ -201,21 +187,19 @@ class MountProviderCollection implements IMountProviderCollection, Emitter { /** * Get the mount cache which can be used to search for mounts without setting up the filesystem - * - * @return IUserMountCache */ - public function getMountCache() { + public function getMountCache(): IUserMountCache { return $this->mountCache; } - public function registerRootProvider(IRootMountProvider $provider) { + public function registerRootProvider(IRootMountProvider $provider): void { $this->rootProviders[] = $provider; } /** * Get all root mountpoints * - * @return \OCP\Files\Mount\IMountPoint[] + * @return list<IMountPoint> * @since 20.0.0 */ public function getRootMounts(): array { @@ -226,16 +210,38 @@ class MountProviderCollection implements IMountProviderCollection, Emitter { $mounts = array_reduce($mounts, function (array $mounts, array $providerMounts) { return array_merge($mounts, $providerMounts); }, []); - return $mounts; + + if (count($mounts) === 0) { + throw new \Exception('No root mounts provided by any provider'); + } + + return array_values($mounts); } - public function clearProviders() { + public function clearProviders(): void { $this->providers = []; $this->homeProviders = []; $this->rootProviders = []; } + /** + * @return list<IMountProvider> + */ public function getProviders(): array { return $this->providers; } + + /** + * @return list<IHomeMountProvider> + */ + public function getHomeProviders(): array { + return $this->homeProviders; + } + + /** + * @return list<IRootMountProvider> + */ + public function getRootProviders(): array { + return $this->rootProviders; + } } diff --git a/lib/private/Files/Config/UserMountCache.php b/lib/private/Files/Config/UserMountCache.php index 3540b563742..3e53a67a044 100644 --- a/lib/private/Files/Config/UserMountCache.php +++ b/lib/private/Files/Config/UserMountCache.php @@ -1,40 +1,23 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Dariusz Olszewski <starypatyk@users.noreply.github.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Config; +use OC\User\LazyUser; use OCP\Cache\CappedMemoryCache; -use OCA\Files_Sharing\SharedMount; use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Diagnostics\IEventLogger; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Config\Event\UserMountAddedEvent; +use OCP\Files\Config\Event\UserMountRemovedEvent; +use OCP\Files\Config\Event\UserMountUpdatedEvent; use OCP\Files\Config\ICachedMountFileInfo; use OCP\Files\Config\ICachedMountInfo; use OCP\Files\Config\IUserMountCache; -use OCP\Files\Mount\IMountPoint; use OCP\Files\NotFoundException; use OCP\IDBConnection; use OCP\IUser; @@ -45,119 +28,134 @@ use Psr\Log\LoggerInterface; * Cache mounts points per user in the cache so we can easily look them up */ class UserMountCache implements IUserMountCache { - private IDBConnection $connection; - private IUserManager $userManager; /** * Cached mount info. * @var CappedMemoryCache<ICachedMountInfo[]> **/ private CappedMemoryCache $mountsForUsers; - private LoggerInterface $logger; + /** + * fileid => internal path mapping for cached mount info. + * @var CappedMemoryCache<string> + **/ + private CappedMemoryCache $internalPathCache; /** @var CappedMemoryCache<array> */ private CappedMemoryCache $cacheInfoCache; /** * UserMountCache constructor. */ - public function __construct(IDBConnection $connection, IUserManager $userManager, LoggerInterface $logger) { - $this->connection = $connection; - $this->userManager = $userManager; - $this->logger = $logger; + public function __construct( + private IDBConnection $connection, + private IUserManager $userManager, + private LoggerInterface $logger, + private IEventLogger $eventLogger, + private IEventDispatcher $eventDispatcher, + ) { $this->cacheInfoCache = new CappedMemoryCache(); + $this->internalPathCache = new CappedMemoryCache(); $this->mountsForUsers = new CappedMemoryCache(); } - public function registerMounts(IUser $user, array $mounts, array $mountProviderClasses = null) { - // filter out non-proper storages coming from unit tests - $mounts = array_filter($mounts, function (IMountPoint $mount) { - return $mount instanceof SharedMount || ($mount->getStorage() && $mount->getStorage()->getCache()); - }); - /** @var ICachedMountInfo[] $newMounts */ - $newMounts = array_map(function (IMountPoint $mount) use ($user) { + public function registerMounts(IUser $user, array $mounts, ?array $mountProviderClasses = null) { + $this->eventLogger->start('fs:setup:user:register', 'Registering mounts for user'); + /** @var array<string, ICachedMountInfo> $newMounts */ + $newMounts = []; + foreach ($mounts as $mount) { // filter out any storages which aren't scanned yet since we aren't interested in files from those storages (yet) - if ($mount->getStorageRootId() === -1) { - return null; - } else { - return new LazyStorageMountInfo($user, $mount); + if ($mount->getStorageRootId() !== -1) { + $mountInfo = new LazyStorageMountInfo($user, $mount); + $newMounts[$mountInfo->getKey()] = $mountInfo; } - }, $mounts); - $newMounts = array_values(array_filter($newMounts)); - $newMountRootIds = array_map(function (ICachedMountInfo $mount) { - return $mount->getRootId(); - }, $newMounts); - $newMounts = array_combine($newMountRootIds, $newMounts); + } $cachedMounts = $this->getMountsForUser($user); if (is_array($mountProviderClasses)) { $cachedMounts = array_filter($cachedMounts, function (ICachedMountInfo $mountInfo) use ($mountProviderClasses, $newMounts) { // for existing mounts that didn't have a mount provider set // we still want the ones that map to new mounts - if ($mountInfo->getMountProvider() === '' && isset($newMounts[$mountInfo->getRootId()])) { + if ($mountInfo->getMountProvider() === '' && isset($newMounts[$mountInfo->getKey()])) { return true; } return in_array($mountInfo->getMountProvider(), $mountProviderClasses); }); } - $cachedMountRootIds = array_map(function (ICachedMountInfo $mount) { - return $mount->getRootId(); - }, $cachedMounts); - $cachedMounts = array_combine($cachedMountRootIds, $cachedMounts); $addedMounts = []; $removedMounts = []; - foreach ($newMounts as $rootId => $newMount) { - if (!isset($cachedMounts[$rootId])) { + foreach ($newMounts as $mountKey => $newMount) { + if (!isset($cachedMounts[$mountKey])) { $addedMounts[] = $newMount; } } - foreach ($cachedMounts as $rootId => $cachedMount) { - if (!isset($newMounts[$rootId])) { + foreach ($cachedMounts as $mountKey => $cachedMount) { + if (!isset($newMounts[$mountKey])) { $removedMounts[] = $cachedMount; } } $changedMounts = $this->findChangedMounts($newMounts, $cachedMounts); - foreach ($addedMounts as $mount) { - $this->addToCache($mount); - /** @psalm-suppress InvalidArgument */ - $this->mountsForUsers[$user->getUID()][] = $mount; - } - foreach ($removedMounts as $mount) { - $this->removeFromCache($mount); - $index = array_search($mount, $this->mountsForUsers[$user->getUID()]); - unset($this->mountsForUsers[$user->getUID()][$index]); - } - foreach ($changedMounts as $mount) { - $this->updateCachedMount($mount); + if ($addedMounts || $removedMounts || $changedMounts) { + $this->connection->beginTransaction(); + $userUID = $user->getUID(); + try { + foreach ($addedMounts as $mount) { + $this->logger->debug("Adding mount '{$mount->getKey()}' for user '$userUID'", ['app' => 'files', 'mount_provider' => $mount->getMountProvider()]); + $this->addToCache($mount); + /** @psalm-suppress InvalidArgument */ + $this->mountsForUsers[$userUID][$mount->getKey()] = $mount; + } + foreach ($removedMounts as $mount) { + $this->logger->debug("Removing mount '{$mount->getKey()}' for user '$userUID'", ['app' => 'files', 'mount_provider' => $mount->getMountProvider()]); + $this->removeFromCache($mount); + unset($this->mountsForUsers[$userUID][$mount->getKey()]); + } + foreach ($changedMounts as $mountPair) { + $newMount = $mountPair[1]; + $this->logger->debug("Updating mount '{$newMount->getKey()}' for user '$userUID'", ['app' => 'files', 'mount_provider' => $newMount->getMountProvider()]); + $this->updateCachedMount($newMount); + /** @psalm-suppress InvalidArgument */ + $this->mountsForUsers[$userUID][$newMount->getKey()] = $newMount; + } + $this->connection->commit(); + } catch (\Throwable $e) { + $this->connection->rollBack(); + throw $e; + } + + // Only fire events after all mounts have already been adjusted in the database. + foreach ($addedMounts as $mount) { + $this->eventDispatcher->dispatchTyped(new UserMountAddedEvent($mount)); + } + foreach ($removedMounts as $mount) { + $this->eventDispatcher->dispatchTyped(new UserMountRemovedEvent($mount)); + } + foreach ($changedMounts as $mountPair) { + $this->eventDispatcher->dispatchTyped(new UserMountUpdatedEvent($mountPair[0], $mountPair[1])); + } } + $this->eventLogger->end('fs:setup:user:register'); } /** - * @param ICachedMountInfo[] $newMounts - * @param ICachedMountInfo[] $cachedMounts - * @return ICachedMountInfo[] + * @param array<string, ICachedMountInfo> $newMounts + * @param array<string, ICachedMountInfo> $cachedMounts + * @return list<list{0: ICachedMountInfo, 1: ICachedMountInfo}> Pairs of old and new mounts */ - private function findChangedMounts(array $newMounts, array $cachedMounts) { - $new = []; - foreach ($newMounts as $mount) { - $new[$mount->getRootId()] = $mount; - } + private function findChangedMounts(array $newMounts, array $cachedMounts): array { $changed = []; - foreach ($cachedMounts as $cachedMount) { - $rootId = $cachedMount->getRootId(); - if (isset($new[$rootId])) { - $newMount = $new[$rootId]; + foreach ($cachedMounts as $key => $cachedMount) { + if (isset($newMounts[$key])) { + $newMount = $newMounts[$key]; if ( - $newMount->getMountPoint() !== $cachedMount->getMountPoint() || - $newMount->getStorageId() !== $cachedMount->getStorageId() || - $newMount->getMountId() !== $cachedMount->getMountId() || - $newMount->getMountProvider() !== $cachedMount->getMountProvider() + $newMount->getStorageId() !== $cachedMount->getStorageId() + || $newMount->getMountId() !== $cachedMount->getMountId() + || $newMount->getMountProvider() !== $cachedMount->getMountProvider() ) { - $changed[] = $newMount; + $changed[] = [$cachedMount, $newMount]; } } } @@ -173,7 +171,7 @@ class UserMountCache implements IUserMountCache { 'mount_point' => $mount->getMountPoint(), 'mount_id' => $mount->getMountId(), 'mount_provider_class' => $mount->getMountProvider(), - ], ['root_id', 'user_id']); + ], ['root_id', 'user_id', 'mount_point']); } else { // in some cases this is legitimate, like orphaned shares $this->logger->debug('Could not get storage info for mount at ' . $mount->getMountPoint()); @@ -191,7 +189,7 @@ class UserMountCache implements IUserMountCache { ->where($builder->expr()->eq('user_id', $builder->createNamedParameter($mount->getUser()->getUID()))) ->andWhere($builder->expr()->eq('root_id', $builder->createNamedParameter($mount->getRootId(), IQueryBuilder::PARAM_INT))); - $query->execute(); + $query->executeStatement(); } private function removeFromCache(ICachedMountInfo $mount) { @@ -199,28 +197,43 @@ class UserMountCache implements IUserMountCache { $query = $builder->delete('mounts') ->where($builder->expr()->eq('user_id', $builder->createNamedParameter($mount->getUser()->getUID()))) - ->andWhere($builder->expr()->eq('root_id', $builder->createNamedParameter($mount->getRootId(), IQueryBuilder::PARAM_INT))); - $query->execute(); + ->andWhere($builder->expr()->eq('root_id', $builder->createNamedParameter($mount->getRootId(), IQueryBuilder::PARAM_INT))) + ->andWhere($builder->expr()->eq('mount_point', $builder->createNamedParameter($mount->getMountPoint()))); + $query->executeStatement(); } - private function dbRowToMountInfo(array $row) { - $user = $this->userManager->get($row['user_id']); - if (is_null($user)) { - return null; - } + /** + * @param array $row + * @param (callable(CachedMountInfo): string)|null $pathCallback + * @return CachedMountInfo + */ + private function dbRowToMountInfo(array $row, ?callable $pathCallback = null): ICachedMountInfo { + $user = new LazyUser($row['user_id'], $this->userManager); $mount_id = $row['mount_id']; if (!is_null($mount_id)) { $mount_id = (int)$mount_id; } - return new CachedMountInfo( - $user, - (int)$row['storage_id'], - (int)$row['root_id'], - $row['mount_point'], - $row['mount_provider_class'] ?? '', - $mount_id, - isset($row['path']) ? $row['path'] : '', - ); + if ($pathCallback) { + return new LazyPathCachedMountInfo( + $user, + (int)$row['storage_id'], + (int)$row['root_id'], + $row['mount_point'], + $row['mount_provider_class'] ?? '', + $mount_id, + $pathCallback, + ); + } else { + return new CachedMountInfo( + $user, + (int)$row['storage_id'], + (int)$row['root_id'], + $row['mount_point'], + $row['mount_provider_class'] ?? '', + $mount_id, + $row['path'] ?? '', + ); + } } /** @@ -228,20 +241,43 @@ class UserMountCache implements IUserMountCache { * @return ICachedMountInfo[] */ public function getMountsForUser(IUser $user) { - if (!isset($this->mountsForUsers[$user->getUID()])) { + $userUID = $user->getUID(); + if (!$this->userManager->userExists($userUID)) { + return []; + } + if (!isset($this->mountsForUsers[$userUID])) { $builder = $this->connection->getQueryBuilder(); - $query = $builder->select('storage_id', 'root_id', 'user_id', 'mount_point', 'mount_id', 'f.path', 'mount_provider_class') + $query = $builder->select('storage_id', 'root_id', 'user_id', 'mount_point', 'mount_id', 'mount_provider_class') ->from('mounts', 'm') - ->innerJoin('m', 'filecache', 'f', $builder->expr()->eq('m.root_id', 'f.fileid')) - ->where($builder->expr()->eq('user_id', $builder->createPositionalParameter($user->getUID()))); + ->where($builder->expr()->eq('user_id', $builder->createNamedParameter($userUID))); - $result = $query->execute(); + $result = $query->executeQuery(); $rows = $result->fetchAll(); $result->closeCursor(); - $this->mountsForUsers[$user->getUID()] = array_filter(array_map([$this, 'dbRowToMountInfo'], $rows)); + /** @var array<string, ICachedMountInfo> $mounts */ + $mounts = []; + foreach ($rows as $row) { + $mount = $this->dbRowToMountInfo($row, [$this, 'getInternalPathForMountInfo']); + if ($mount !== null) { + $mounts[$mount->getKey()] = $mount; + } + } + $this->mountsForUsers[$userUID] = $mounts; } - return $this->mountsForUsers[$user->getUID()]; + return $this->mountsForUsers[$userUID]; + } + + public function getInternalPathForMountInfo(CachedMountInfo $info): string { + $cached = $this->internalPathCache->get($info->getRootId()); + if ($cached !== null) { + return $cached; + } + $builder = $this->connection->getQueryBuilder(); + $query = $builder->select('path') + ->from('filecache') + ->where($builder->expr()->eq('fileid', $builder->createNamedParameter($info->getRootId()))); + return $query->executeQuery()->fetchOne() ?: ''; } /** @@ -254,13 +290,13 @@ class UserMountCache implements IUserMountCache { $query = $builder->select('storage_id', 'root_id', 'user_id', 'mount_point', 'mount_id', 'f.path', 'mount_provider_class') ->from('mounts', 'm') ->innerJoin('m', 'filecache', 'f', $builder->expr()->eq('m.root_id', 'f.fileid')) - ->where($builder->expr()->eq('storage_id', $builder->createPositionalParameter($numericStorageId, IQueryBuilder::PARAM_INT))); + ->where($builder->expr()->eq('storage_id', $builder->createNamedParameter($numericStorageId, IQueryBuilder::PARAM_INT))); if ($user) { - $query->andWhere($builder->expr()->eq('user_id', $builder->createPositionalParameter($user))); + $query->andWhere($builder->expr()->eq('user_id', $builder->createNamedParameter($user))); } - $result = $query->execute(); + $result = $query->executeQuery(); $rows = $result->fetchAll(); $result->closeCursor(); @@ -276,9 +312,9 @@ class UserMountCache implements IUserMountCache { $query = $builder->select('storage_id', 'root_id', 'user_id', 'mount_point', 'mount_id', 'f.path', 'mount_provider_class') ->from('mounts', 'm') ->innerJoin('m', 'filecache', 'f', $builder->expr()->eq('m.root_id', 'f.fileid')) - ->where($builder->expr()->eq('root_id', $builder->createPositionalParameter($rootFileId, IQueryBuilder::PARAM_INT))); + ->where($builder->expr()->eq('root_id', $builder->createNamedParameter($rootFileId, IQueryBuilder::PARAM_INT))); - $result = $query->execute(); + $result = $query->executeQuery(); $rows = $result->fetchAll(); $result->closeCursor(); @@ -297,7 +333,7 @@ class UserMountCache implements IUserMountCache { ->from('filecache') ->where($builder->expr()->eq('fileid', $builder->createNamedParameter($fileId, IQueryBuilder::PARAM_INT))); - $result = $query->execute(); + $result = $query->executeQuery(); $row = $result->fetch(); $result->closeCursor(); @@ -326,30 +362,22 @@ class UserMountCache implements IUserMountCache { } catch (NotFoundException $e) { return []; } - $builder = $this->connection->getQueryBuilder(); - $query = $builder->select('storage_id', 'root_id', 'user_id', 'mount_point', 'mount_id', 'f.path', 'mount_provider_class') - ->from('mounts', 'm') - ->innerJoin('m', 'filecache', 'f', $builder->expr()->eq('m.root_id', 'f.fileid')) - ->where($builder->expr()->eq('storage_id', $builder->createPositionalParameter($storageId, IQueryBuilder::PARAM_INT))); + $mountsForStorage = $this->getMountsForStorageId($storageId, $user); - if ($user) { - $query->andWhere($builder->expr()->eq('user_id', $builder->createPositionalParameter($user))); - } - - $result = $query->execute(); - $rows = $result->fetchAll(); - $result->closeCursor(); - // filter mounts that are from the same storage but a different directory - $filteredMounts = array_filter($rows, function (array $row) use ($internalPath, $fileId) { - if ($fileId === (int)$row['root_id']) { + // filter mounts that are from the same storage but not a parent of the file we care about + $filteredMounts = array_filter($mountsForStorage, function (ICachedMountInfo $mount) use ($internalPath, $fileId) { + if ($fileId === $mount->getRootId()) { return true; } - $internalMountPath = $row['path'] ?? ''; + $internalMountPath = $mount->getRootInternalPath(); - return $internalMountPath === '' || substr($internalPath, 0, strlen($internalMountPath) + 1) === $internalMountPath . '/'; + return $internalMountPath === '' || str_starts_with($internalPath, $internalMountPath . '/'); }); - $filteredMounts = array_filter(array_map([$this, 'dbRowToMountInfo'], $filteredMounts)); + $filteredMounts = array_values(array_filter($filteredMounts, function (ICachedMountInfo $mount) { + return $this->userManager->userExists($mount->getUser()->getUID()); + })); + return array_map(function (ICachedMountInfo $mount) use ($internalPath) { return new CachedMountFileInfo( $mount->getUser(), @@ -374,7 +402,7 @@ class UserMountCache implements IUserMountCache { $query = $builder->delete('mounts') ->where($builder->expr()->eq('user_id', $builder->createNamedParameter($user->getUID()))); - $query->execute(); + $query->executeStatement(); } public function removeUserStorageMount($storageId, $userId) { @@ -383,7 +411,7 @@ class UserMountCache implements IUserMountCache { $query = $builder->delete('mounts') ->where($builder->expr()->eq('user_id', $builder->createNamedParameter($userId))) ->andWhere($builder->expr()->eq('storage_id', $builder->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))); - $query->execute(); + $query->executeStatement(); } public function remoteStorageMounts($storageId) { @@ -391,7 +419,7 @@ class UserMountCache implements IUserMountCache { $query = $builder->delete('mounts') ->where($builder->expr()->eq('storage_id', $builder->createNamedParameter($storageId, IQueryBuilder::PARAM_INT))); - $query->execute(); + $query->executeStatement(); } /** @@ -422,7 +450,7 @@ class UserMountCache implements IUserMountCache { ->where($builder->expr()->eq('m.mount_point', $mountPoint)) ->andWhere($builder->expr()->in('m.user_id', $builder->createNamedParameter($userIds, IQueryBuilder::PARAM_STR_ARRAY))); - $result = $query->execute(); + $result = $query->executeQuery(); $results = []; while ($row = $result->fetch()) { @@ -444,7 +472,7 @@ class UserMountCache implements IUserMountCache { }, $mounts); $mounts = array_combine($mountPoints, $mounts); - $current = $path; + $current = rtrim($path, '/'); // walk up the directory tree until we find a path that has a mountpoint set // the loop will return if a mountpoint is found or break if none are found while (true) { @@ -461,14 +489,14 @@ class UserMountCache implements IUserMountCache { } } - throw new NotFoundException("No cached mount for path " . $path); + throw new NotFoundException('No cached mount for path ' . $path); } public function getMountsInPath(IUser $user, string $path): array { $path = rtrim($path, '/') . '/'; $mounts = $this->getMountsForUser($user); return array_filter($mounts, function (ICachedMountInfo $mount) use ($path) { - return $mount->getMountPoint() !== $path && strpos($mount->getMountPoint(), $path) === 0; + return $mount->getMountPoint() !== $path && str_starts_with($mount->getMountPoint(), $path); }); } } diff --git a/lib/private/Files/Config/UserMountCacheListener.php b/lib/private/Files/Config/UserMountCacheListener.php index eef91a03853..40995de8986 100644 --- a/lib/private/Files/Config/UserMountCacheListener.php +++ b/lib/private/Files/Config/UserMountCacheListener.php @@ -1,23 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Config; diff --git a/lib/private/Files/Conversion/ConversionManager.php b/lib/private/Files/Conversion/ConversionManager.php new file mode 100644 index 00000000000..2c98a4c6404 --- /dev/null +++ b/lib/private/Files/Conversion/ConversionManager.php @@ -0,0 +1,181 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Files\Conversion; + +use OC\AppFramework\Bootstrap\Coordinator; +use OC\ForbiddenException; +use OC\SystemConfig; +use OCP\Files\Conversion\IConversionManager; +use OCP\Files\Conversion\IConversionProvider; +use OCP\Files\File; +use OCP\Files\GenericFileException; +use OCP\Files\IRootFolder; +use OCP\IL10N; +use OCP\ITempManager; +use OCP\L10N\IFactory; +use OCP\PreConditionNotMetException; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Psr\Container\NotFoundExceptionInterface; +use Psr\Log\LoggerInterface; +use RuntimeException; +use Throwable; + +class ConversionManager implements IConversionManager { + /** @var string[] */ + private array $preferredApps = [ + 'richdocuments', + ]; + + /** @var list<IConversionProvider> */ + private array $preferredProviders = []; + + /** @var list<IConversionProvider> */ + private array $providers = []; + + private IL10N $l10n; + public function __construct( + private Coordinator $coordinator, + private ContainerInterface $serverContainer, + private IRootFolder $rootFolder, + private ITempManager $tempManager, + private LoggerInterface $logger, + private SystemConfig $config, + IFactory $l10nFactory, + ) { + $this->l10n = $l10nFactory->get('files'); + } + + public function hasProviders(): bool { + $context = $this->coordinator->getRegistrationContext(); + return !empty($context->getFileConversionProviders()); + } + + public function getProviders(): array { + $providers = []; + foreach ($this->getRegisteredProviders() as $provider) { + $providers = array_merge($providers, $provider->getSupportedMimeTypes()); + } + return $providers; + } + + public function convert(File $file, string $targetMimeType, ?string $destination = null): string { + if (!$this->hasProviders()) { + throw new PreConditionNotMetException($this->l10n->t('No file conversion providers available')); + } + + // Operate in mebibytes + $fileSize = $file->getSize() / (1024 * 1024); + $threshold = $this->config->getValue('max_file_conversion_filesize', 100); + if ($fileSize > $threshold) { + throw new GenericFileException($this->l10n->t('File is too large to convert')); + } + + $fileMimeType = $file->getMimetype(); + $validProvider = $this->getValidProvider($fileMimeType, $targetMimeType); + + if ($validProvider !== null) { + // Get the target extension given by the provider + $targetExtension = ''; + foreach ($validProvider->getSupportedMimeTypes() as $mimeProvider) { + if ($mimeProvider->getTo() === $targetMimeType) { + $targetExtension = $mimeProvider->getExtension(); + break; + } + } + // If destination not provided, we use the same path + // as the original file, but with the new extension + if ($destination === null) { + $basename = pathinfo($file->getPath(), PATHINFO_FILENAME); + $parent = $file->getParent(); + $destination = $parent->getFullPath($basename . '.' . $targetExtension); + } + + // If destination doesn't match the target extension, we throw an error + if (pathinfo($destination, PATHINFO_EXTENSION) !== $targetExtension) { + throw new GenericFileException($this->l10n->t('Destination does not match conversion extension')); + } + + // Check destination before converting + $this->checkDestination($destination); + + // Convert the file and write it to the destination + $convertedFile = $validProvider->convertFile($file, $targetMimeType); + $convertedFile = $this->writeToDestination($destination, $convertedFile); + return $convertedFile->getPath(); + } + + throw new RuntimeException($this->l10n->t('Could not convert file')); + } + + /** + * @return list<IConversionProvider> + */ + private function getRegisteredProviders(): array { + $context = $this->coordinator->getRegistrationContext(); + foreach ($context->getFileConversionProviders() as $providerRegistration) { + $class = $providerRegistration->getService(); + $appId = $providerRegistration->getAppId(); + + try { + if (in_array($appId, $this->preferredApps)) { + $this->preferredProviders[$class] = $this->serverContainer->get($class); + continue; + } + + $this->providers[$class] = $this->serverContainer->get($class); + } catch (NotFoundExceptionInterface|ContainerExceptionInterface|Throwable $e) { + $this->logger->error('Failed to load file conversion provider ' . $class, [ + 'exception' => $e, + ]); + } + } + + return array_values(array_merge([], $this->preferredProviders, $this->providers)); + } + + private function checkDestination(string $destination): void { + if (!$this->rootFolder->nodeExists(dirname($destination))) { + throw new ForbiddenException($this->l10n->t('Destination does not exist')); + } + + $folder = $this->rootFolder->get(dirname($destination)); + if (!$folder->isCreatable()) { + throw new ForbiddenException($this->l10n->t('Destination is not creatable')); + } + } + + private function writeToDestination(string $destination, mixed $content): File { + $this->checkDestination($destination); + + if ($this->rootFolder->nodeExists($destination)) { + $file = $this->rootFolder->get($destination); + $parent = $file->getParent(); + + // Folder permissions is already checked in checkDestination method + $newName = $parent->getNonExistingName(basename($destination)); + $destination = $parent->getFullPath($newName); + } + + return $this->rootFolder->newFile($destination, $content); + } + + private function getValidProvider(string $fileMimeType, string $targetMimeType): ?IConversionProvider { + foreach ($this->getRegisteredProviders() as $provider) { + foreach ($provider->getSupportedMimeTypes() as $mimeProvider) { + if ($mimeProvider->getFrom() === $fileMimeType && $mimeProvider->getTo() === $targetMimeType) { + return $provider; + } + } + } + + return null; + } +} diff --git a/lib/private/Files/FileInfo.php b/lib/private/Files/FileInfo.php index 47c893ebbf1..0679dc1ae72 100644 --- a/lib/private/Files/FileInfo.php +++ b/lib/private/Files/FileInfo.php @@ -1,49 +1,26 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Piotr M <mrow4a@yahoo.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author tbartenstein <tbartenstein@users.noreply.github.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files; +use OC\Files\Mount\HomeMountPoint; +use OCA\Files_Sharing\External\Mount; +use OCA\Files_Sharing\ISharedMountPoint; use OCP\Files\Cache\ICacheEntry; use OCP\Files\Mount\IMountPoint; use OCP\IUser; +/** + * @template-implements \ArrayAccess<string,mixed> + */ class FileInfo implements \OCP\Files\FileInfo, \ArrayAccess { + private array|ICacheEntry $data; /** - * @var array $data - */ - private $data; - - /** - * @var string $path + * @var string */ private $path; @@ -53,7 +30,7 @@ class FileInfo implements \OCP\Files\FileInfo, \ArrayAccess { private $storage; /** - * @var string $internalPath + * @var string */ private $internalPath; @@ -62,37 +39,32 @@ class FileInfo implements \OCP\Files\FileInfo, \ArrayAccess { */ private $mount; - /** - * @var IUser - */ - private $owner; + private ?IUser $owner; /** * @var string[] */ - private $childEtags = []; + private array $childEtags = []; /** * @var IMountPoint[] */ - private $subMounts = []; + private array $subMounts = []; - private $subMountsUsed = false; + private bool $subMountsUsed = false; /** * The size of the file/folder without any sub mount - * - * @var int */ - private $rawSize = 0; + private int|float $rawSize = 0; /** * @param string|boolean $path * @param Storage\Storage $storage * @param string $internalPath * @param array|ICacheEntry $data - * @param \OCP\Files\Mount\IMountPoint $mount - * @param \OCP\IUser|null $owner + * @param IMountPoint $mount + * @param ?IUser $owner */ public function __construct($path, $storage, $internalPath, $data, $mount, $owner = null) { $this->path = $path; @@ -109,6 +81,9 @@ class FileInfo implements \OCP\Files\FileInfo, \ArrayAccess { } public function offsetSet($offset, $value): void { + if (is_null($offset)) { + throw new \TypeError('Null offset not supported'); + } $this->data[$offset] = $value; } @@ -120,26 +95,15 @@ class FileInfo implements \OCP\Files\FileInfo, \ArrayAccess { unset($this->data[$offset]); } - /** - * @return mixed - */ - #[\ReturnTypeWillChange] - public function offsetGet($offset) { - if ($offset === 'type') { - return $this->getType(); - } elseif ($offset === 'etag') { - return $this->getEtag(); - } elseif ($offset === 'size') { - return $this->getSize(); - } elseif ($offset === 'mtime') { - return $this->getMTime(); - } elseif ($offset === 'permissions') { - return $this->getPermissions(); - } elseif (isset($this->data[$offset])) { - return $this->data[$offset]; - } else { - return null; - } + public function offsetGet(mixed $offset): mixed { + return match ($offset) { + 'type' => $this->getType(), + 'etag' => $this->getEtag(), + 'size' => $this->getSize(), + 'mtime' => $this->getMTime(), + 'permissions' => $this->getPermissions(), + default => $this->data[$offset] ?? null, + }; } /** @@ -149,9 +113,6 @@ class FileInfo implements \OCP\Files\FileInfo, \ArrayAccess { return $this->path; } - /** - * @return \OCP\Files\Storage - */ public function getStorage() { return $this->storage; } @@ -169,7 +130,7 @@ class FileInfo implements \OCP\Files\FileInfo, \ArrayAccess { * @return int|null */ public function getId() { - return isset($this->data['fileid']) ? (int) $this->data['fileid'] : null; + return isset($this->data['fileid']) ? (int)$this->data['fileid'] : null; } /** @@ -190,14 +151,16 @@ class FileInfo implements \OCP\Files\FileInfo, \ArrayAccess { * @return string */ public function getName() { - return isset($this->data['name']) ? $this->data['name'] : basename($this->getPath()); + return empty($this->data['name']) + ? basename($this->getPath()) + : $this->data['name']; } /** * @return string */ public function getEtag() { - $this->updateEntryfromSubMounts(); + $this->updateEntryFromSubMounts(); if (count($this->childEtags) > 0) { $combinedEtag = $this->data['etag'] . '::' . implode('::', $this->childEtags); return md5($combinedEtag); @@ -207,13 +170,14 @@ class FileInfo implements \OCP\Files\FileInfo, \ArrayAccess { } /** - * @return int + * @param bool $includeMounts + * @return int|float */ public function getSize($includeMounts = true) { if ($includeMounts) { - $this->updateEntryfromSubMounts(); + $this->updateEntryFromSubMounts(); - if (isset($this->data['unencrypted_size']) && $this->data['unencrypted_size'] > 0) { + if ($this->isEncrypted() && isset($this->data['unencrypted_size']) && $this->data['unencrypted_size'] > 0) { return $this->data['unencrypted_size']; } else { return isset($this->data['size']) ? 0 + $this->data['size'] : 0; @@ -227,35 +191,29 @@ class FileInfo implements \OCP\Files\FileInfo, \ArrayAccess { * @return int */ public function getMTime() { - $this->updateEntryfromSubMounts(); - return (int) $this->data['mtime']; + $this->updateEntryFromSubMounts(); + return (int)$this->data['mtime']; } /** * @return bool */ public function isEncrypted() { - return $this->data['encrypted']; + return $this->data['encrypted'] ?? false; } /** - * Return the currently version used for the HMAC in the encryption app - * - * @return int + * Return the current version used for the HMAC in the encryption app */ - public function getEncryptedVersion() { - return isset($this->data['encryptedVersion']) ? (int) $this->data['encryptedVersion'] : 1; + public function getEncryptedVersion(): int { + return isset($this->data['encryptedVersion']) ? (int)$this->data['encryptedVersion'] : 1; } /** * @return int */ public function getPermissions() { - $perms = (int) $this->data['permissions']; - if (\OCP\Util::isSharingDisabledForUser() || ($this->isShared() && !\OC\Share\Share::isResharingAllowed())) { - $perms = $perms & ~\OCP\Constants::PERMISSION_SHARE; - } - return $perms; + return (int)$this->data['permissions']; } /** @@ -323,27 +281,12 @@ class FileInfo implements \OCP\Files\FileInfo, \ArrayAccess { * @return bool */ public function isShared() { - $sid = $this->getStorage()->getId(); - if (!is_null($sid)) { - $sid = explode(':', $sid); - return ($sid[0] === 'shared'); - } - - return false; + return $this->mount instanceof ISharedMountPoint; } public function isMounted() { - $storage = $this->getStorage(); - if ($storage->instanceOfStorage('\OCP\Files\IHomeStorage')) { - return false; - } - $sid = $storage->getId(); - if (!is_null($sid)) { - $sid = explode(':', $sid); - return ($sid[0] !== 'home' and $sid[0] !== 'shared'); - } - - return false; + $isHome = $this->mount instanceof HomeMountPoint; + return !$isHome && !$this->isShared(); } /** @@ -358,7 +301,7 @@ class FileInfo implements \OCP\Files\FileInfo, \ArrayAccess { /** * Get the owner of the file * - * @return \OCP\IUser + * @return ?IUser */ public function getOwner() { return $this->owner; @@ -371,7 +314,7 @@ class FileInfo implements \OCP\Files\FileInfo, \ArrayAccess { $this->subMounts = $mounts; } - private function updateEntryfromSubMounts() { + private function updateEntryFromSubMounts(): void { if ($this->subMountsUsed) { return; } @@ -432,10 +375,22 @@ class FileInfo implements \OCP\Files\FileInfo, \ArrayAccess { } public function getCreationTime(): int { - return (int) $this->data['creation_time']; + return (int)$this->data['creation_time']; } public function getUploadTime(): int { - return (int) $this->data['upload_time']; + return (int)$this->data['upload_time']; + } + + public function getParentId(): int { + return $this->data['parent'] ?? -1; + } + + /** + * @inheritDoc + * @return array<string, int|string|bool|float|string[]|int[]> + */ + public function getMetadata(): array { + return $this->data['metadata'] ?? []; } } diff --git a/lib/private/Files/FilenameValidator.php b/lib/private/Files/FilenameValidator.php new file mode 100644 index 00000000000..a78c6d3cc3c --- /dev/null +++ b/lib/private/Files/FilenameValidator.php @@ -0,0 +1,335 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files; + +use OCP\Files\EmptyFileNameException; +use OCP\Files\FileNameTooLongException; +use OCP\Files\IFilenameValidator; +use OCP\Files\InvalidCharacterInPathException; +use OCP\Files\InvalidDirectoryException; +use OCP\Files\InvalidPathException; +use OCP\Files\ReservedWordException; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IL10N; +use OCP\L10N\IFactory; +use Psr\Log\LoggerInterface; + +/** + * @since 30.0.0 + */ +class FilenameValidator implements IFilenameValidator { + + public const INVALID_FILE_TYPE = 100; + + private IL10N $l10n; + + /** + * @var list<string> + */ + private array $forbiddenNames = []; + + /** + * @var list<string> + */ + private array $forbiddenBasenames = []; + /** + * @var list<string> + */ + private array $forbiddenCharacters = []; + + /** + * @var list<string> + */ + private array $forbiddenExtensions = []; + + public function __construct( + IFactory $l10nFactory, + private IDBConnection $database, + private IConfig $config, + private LoggerInterface $logger, + ) { + $this->l10n = $l10nFactory->get('core'); + } + + /** + * Get a list of reserved filenames that must not be used + * This list should be checked case-insensitive, all names are returned lowercase. + * @return list<string> + * @since 30.0.0 + */ + public function getForbiddenExtensions(): array { + if (empty($this->forbiddenExtensions)) { + $forbiddenExtensions = $this->getConfigValue('forbidden_filename_extensions', ['.filepart']); + + // Always forbid .part files as they are used internally + $forbiddenExtensions[] = '.part'; + + $this->forbiddenExtensions = array_values($forbiddenExtensions); + } + return $this->forbiddenExtensions; + } + + /** + * Get a list of forbidden filename extensions that must not be used + * This list should be checked case-insensitive, all names are returned lowercase. + * @return list<string> + * @since 30.0.0 + */ + public function getForbiddenFilenames(): array { + if (empty($this->forbiddenNames)) { + $forbiddenNames = $this->getConfigValue('forbidden_filenames', ['.htaccess']); + + // Handle legacy config option + // TODO: Drop with Nextcloud 34 + $legacyForbiddenNames = $this->getConfigValue('blacklisted_files', []); + if (!empty($legacyForbiddenNames)) { + $this->logger->warning('System config option "blacklisted_files" is deprecated and will be removed in Nextcloud 34, use "forbidden_filenames" instead.'); + } + $forbiddenNames = array_merge($legacyForbiddenNames, $forbiddenNames); + + // Ensure we are having a proper string list + $this->forbiddenNames = array_values($forbiddenNames); + } + return $this->forbiddenNames; + } + + /** + * Get a list of forbidden file basenames that must not be used + * This list should be checked case-insensitive, all names are returned lowercase. + * @return list<string> + * @since 30.0.0 + */ + public function getForbiddenBasenames(): array { + if (empty($this->forbiddenBasenames)) { + $forbiddenBasenames = $this->getConfigValue('forbidden_filename_basenames', []); + // Ensure we are having a proper string list + $this->forbiddenBasenames = array_values($forbiddenBasenames); + } + return $this->forbiddenBasenames; + } + + /** + * Get a list of characters forbidden in filenames + * + * Note: Characters in the range [0-31] are always forbidden, + * even if not inside this list (see OCP\Files\Storage\IStorage::verifyPath). + * + * @return list<string> + * @since 30.0.0 + */ + public function getForbiddenCharacters(): array { + if (empty($this->forbiddenCharacters)) { + // Get always forbidden characters + $forbiddenCharacters = str_split(\OCP\Constants::FILENAME_INVALID_CHARS); + + // Get admin defined invalid characters + $additionalChars = $this->config->getSystemValue('forbidden_filename_characters', []); + if (!is_array($additionalChars)) { + $this->logger->error('Invalid system config value for "forbidden_filename_characters" is ignored.'); + $additionalChars = []; + } + $forbiddenCharacters = array_merge($forbiddenCharacters, $additionalChars); + + // Handle legacy config option + // TODO: Drop with Nextcloud 34 + $legacyForbiddenCharacters = $this->config->getSystemValue('forbidden_chars', []); + if (!is_array($legacyForbiddenCharacters)) { + $this->logger->error('Invalid system config value for "forbidden_chars" is ignored.'); + $legacyForbiddenCharacters = []; + } + if (!empty($legacyForbiddenCharacters)) { + $this->logger->warning('System config option "forbidden_chars" is deprecated and will be removed in Nextcloud 34, use "forbidden_filename_characters" instead.'); + } + $forbiddenCharacters = array_merge($legacyForbiddenCharacters, $forbiddenCharacters); + + $this->forbiddenCharacters = array_values($forbiddenCharacters); + } + return $this->forbiddenCharacters; + } + + /** + * @inheritdoc + */ + public function isFilenameValid(string $filename): bool { + try { + $this->validateFilename($filename); + } catch (\OCP\Files\InvalidPathException) { + return false; + } + return true; + } + + /** + * @inheritdoc + */ + public function validateFilename(string $filename): void { + $trimmed = trim($filename); + if ($trimmed === '') { + throw new EmptyFileNameException(); + } + + // the special directories . and .. would cause never ending recursion + // we check the trimmed name here to ensure unexpected trimming will not cause severe issues + if ($trimmed === '.' || $trimmed === '..') { + throw new InvalidDirectoryException($this->l10n->t('Dot files are not allowed')); + } + + // 255 characters is the limit on common file systems (ext/xfs) + // oc_filecache has a 250 char length limit for the filename + if (isset($filename[250])) { + throw new FileNameTooLongException(); + } + + if (!$this->database->supports4ByteText()) { + // verify database - e.g. mysql only 3-byte chars + if (preg_match('%(?: + \xF0[\x90-\xBF][\x80-\xBF]{2} # planes 1-3 + | [\xF1-\xF3][\x80-\xBF]{3} # planes 4-15 + | \xF4[\x80-\x8F][\x80-\xBF]{2} # plane 16 +)%xs', $filename)) { + throw new InvalidCharacterInPathException(); + } + } + + $this->checkForbiddenName($filename); + + $this->checkForbiddenExtension($filename); + + $this->checkForbiddenCharacters($filename); + } + + /** + * Check if the filename is forbidden + * @param string $path Path to check the filename + * @return bool True if invalid name, False otherwise + */ + public function isForbidden(string $path): bool { + // We support paths here as this function is also used in some storage internals + $filename = basename($path); + $filename = mb_strtolower($filename); + + if ($filename === '') { + return false; + } + + // Check for forbidden filenames + $forbiddenNames = $this->getForbiddenFilenames(); + if (in_array($filename, $forbiddenNames)) { + return true; + } + + // Filename is not forbidden + return false; + } + + public function sanitizeFilename(string $name, ?string $charReplacement = null): string { + $forbiddenCharacters = $this->getForbiddenCharacters(); + + if ($charReplacement === null) { + $charReplacement = array_diff(['_', '-', ' '], $forbiddenCharacters); + $charReplacement = reset($charReplacement) ?: ''; + } + if (mb_strlen($charReplacement) !== 1) { + throw new \InvalidArgumentException('No or invalid character replacement given'); + } + + $nameLowercase = mb_strtolower($name); + foreach ($this->getForbiddenExtensions() as $extension) { + if (str_ends_with($nameLowercase, $extension)) { + $name = substr($name, 0, strlen($name) - strlen($extension)); + } + } + + $basename = strlen($name) > 1 + ? substr($name, 0, strpos($name, '.', 1) ?: null) + : $name; + if (in_array(mb_strtolower($basename), $this->getForbiddenBasenames())) { + $name = str_replace($basename, $this->l10n->t('%1$s (renamed)', [$basename]), $name); + } + + if ($name === '') { + $name = $this->l10n->t('renamed file'); + } + + if (in_array(mb_strtolower($name), $this->getForbiddenFilenames())) { + $name = $this->l10n->t('%1$s (renamed)', [$name]); + } + + $name = str_replace($forbiddenCharacters, $charReplacement, $name); + return $name; + } + + protected function checkForbiddenName(string $filename): void { + $filename = mb_strtolower($filename); + if ($this->isForbidden($filename)) { + throw new ReservedWordException($this->l10n->t('"%1$s" is a forbidden file or folder name.', [$filename])); + } + + // Check for forbidden basenames - basenames are the part of the file until the first dot + // (except if the dot is the first character as this is then part of the basename "hidden files") + $basename = substr($filename, 0, strpos($filename, '.', 1) ?: null); + $forbiddenNames = $this->getForbiddenBasenames(); + if (in_array($basename, $forbiddenNames)) { + throw new ReservedWordException($this->l10n->t('"%1$s" is a forbidden prefix for file or folder names.', [$filename])); + } + } + + + /** + * Check if a filename contains any of the forbidden characters + * @param string $filename + * @throws InvalidCharacterInPathException + */ + protected function checkForbiddenCharacters(string $filename): void { + $sanitizedFileName = filter_var($filename, FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW); + if ($sanitizedFileName !== $filename) { + throw new InvalidCharacterInPathException(); + } + + foreach ($this->getForbiddenCharacters() as $char) { + if (str_contains($filename, $char)) { + throw new InvalidCharacterInPathException($this->l10n->t('"%1$s" is not allowed inside a file or folder name.', [$char])); + } + } + } + + /** + * Check if a filename has a forbidden filename extension + * @param string $filename The filename to validate + * @throws InvalidPathException + */ + protected function checkForbiddenExtension(string $filename): void { + $filename = mb_strtolower($filename); + // Check for forbidden filename extensions + $forbiddenExtensions = $this->getForbiddenExtensions(); + foreach ($forbiddenExtensions as $extension) { + if (str_ends_with($filename, $extension)) { + if (str_starts_with($extension, '.')) { + throw new InvalidPathException($this->l10n->t('"%1$s" is a forbidden file type.', [$extension]), self::INVALID_FILE_TYPE); + } else { + throw new InvalidPathException($this->l10n->t('Filenames must not end with "%1$s".', [$extension])); + } + } + } + } + + /** + * Helper to get lower case list from config with validation + * @return string[] + */ + private function getConfigValue(string $key, array $fallback): array { + $values = $this->config->getSystemValue($key, $fallback); + if (!is_array($values)) { + $this->logger->error('Invalid system config value for "' . $key . '" is ignored.'); + $values = $fallback; + } + + return array_map(mb_strtolower(...), $values); + } +}; diff --git a/lib/private/Files/Filesystem.php b/lib/private/Files/Filesystem.php index 64a6fc57b27..8fe56cf060c 100644 --- a/lib/private/Files/Filesystem.php +++ b/lib/private/Files/Filesystem.php @@ -1,73 +1,37 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Florin Peter <github@florin-peter.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author korelstar <korelstar@users.noreply.github.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Michael Gapczynski <GapczynskiM@gmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Sam Tuke <mail@samtuke.com> - * @author Stephan Peijnik <speijnik@anexia-it.com> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files; -use OCP\Cache\CappedMemoryCache; use OC\Files\Mount\MountPoint; +use OC\Files\Storage\StorageFactory; use OC\User\NoUserException; +use OCP\Cache\CappedMemoryCache; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Events\Node\FilesystemTornDownEvent; +use OCP\Files\Mount\IMountManager; use OCP\Files\NotFoundException; use OCP\Files\Storage\IStorageFactory; use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; +use Psr\Log\LoggerInterface; class Filesystem { - /** - * @var Mount\Manager $mounts - */ - private static $mounts; - - public static $loaded = false; - /** - * @var \OC\Files\View $defaultInstance - */ - private static $defaultInstance; + private static ?Mount\Manager $mounts = null; - private static $usersSetup = []; + public static bool $loaded = false; - private static $normalizedPathCache = null; + private static ?View $defaultInstance = null; - private static $listeningForProviders = false; + private static ?CappedMemoryCache $normalizedPathCache = null; /** @var string[]|null */ - private static $blacklist = null; + private static ?array $blacklist = null; /** * classname which used for hooks handling @@ -186,22 +150,18 @@ class Filesystem { public const signal_param_mount_type = 'mounttype'; public const signal_param_users = 'users'; - /** - * @var \OC\Files\Storage\StorageFactory $loader - */ - private static $loader; + private static ?\OC\Files\Storage\StorageFactory $loader = null; - /** @var bool */ - private static $logWarningWhenAddingStorageWrapper = true; + private static bool $logWarningWhenAddingStorageWrapper = true; /** * @param bool $shouldLog * @return bool previous value * @internal */ - public static function logWarningWhenAddingStorageWrapper($shouldLog) { + public static function logWarningWhenAddingStorageWrapper(bool $shouldLog): bool { $previousValue = self::$logWarningWhenAddingStorageWrapper; - self::$logWarningWhenAddingStorageWrapper = (bool) $shouldLog; + self::$logWarningWhenAddingStorageWrapper = $shouldLog; return $previousValue; } @@ -212,14 +172,16 @@ class Filesystem { */ public static function addStorageWrapper($wrapperName, $wrapper, $priority = 50) { if (self::$logWarningWhenAddingStorageWrapper) { - \OC::$server->getLogger()->warning("Storage wrapper '{wrapper}' was not registered via the 'OC_Filesystem - preSetup' hook which could cause potential problems.", [ + \OCP\Server::get(LoggerInterface::class)->warning("Storage wrapper '{wrapper}' was not registered via the 'OC_Filesystem - preSetup' hook which could cause potential problems.", [ 'wrapper' => $wrapperName, 'app' => 'filesystem', ]); } $mounts = self::getMountManager()->getAll(); - if (!self::getLoader()->addStorageWrapper($wrapperName, $wrapper, $priority, $mounts)) { + /** @var StorageFactory $loader */ + $loader = self::getLoader(); + if (!$loader->addStorageWrapper($wrapperName, $wrapper, $priority, $mounts)) { // do not re-wrap if storage with this name already existed return; } @@ -232,18 +194,17 @@ class Filesystem { */ public static function getLoader() { if (!self::$loader) { - self::$loader = \OC::$server->query(IStorageFactory::class); + self::$loader = \OC::$server->get(IStorageFactory::class); } return self::$loader; } /** * Returns the mount manager - * - * @return \OC\Files\Mount\Manager */ - public static function getMountManager($user = '') { + public static function getMountManager(): Mount\Manager { self::initMountManager(); + assert(self::$mounts !== null); return self::$mounts; } @@ -313,14 +274,14 @@ class Filesystem { * resolve a path to a storage and internal path * * @param string $path - * @return array an array consisting of the storage and the internal path + * @return array{?\OCP\Files\Storage\IStorage, string} an array consisting of the storage and the internal path */ - public static function resolvePath($path) { + public static function resolvePath($path): array { $mount = self::getMountManager()->find($path); return [$mount->getStorage(), rtrim($mount->getInternalPath($path), '/')]; } - public static function init($user, $root) { + public static function init(string|IUser|null $user, string $root): bool { if (self::$defaultInstance) { return false; } @@ -332,7 +293,7 @@ class Filesystem { return true; } - public static function initInternal($root) { + public static function initInternal(string $root): bool { if (self::$defaultInstance) { return false; } @@ -342,32 +303,28 @@ class Filesystem { $eventDispatcher = \OC::$server->get(IEventDispatcher::class); $eventDispatcher->addListener(FilesystemTornDownEvent::class, function () { self::$defaultInstance = null; - self::$usersSetup = []; self::$loaded = false; }); - if (!self::$mounts) { - self::$mounts = \OC::$server->getMountManager(); - } + self::initMountManager(); self::$loaded = true; return true; } - public static function initMountManager() { + public static function initMountManager(): void { if (!self::$mounts) { - self::$mounts = \OC::$server->getMountManager(); + self::$mounts = \OC::$server->get(IMountManager::class); } } /** * Initialize system and personal mount points for a user * - * @param string|IUser|null $user * @throws \OC\User\NoUserException if the user is not available */ - public static function initMountPoints($user = '') { + public static function initMountPoints(string|IUser|null $user = ''): void { /** @var IUserManager $userManager */ $userManager = \OC::$server->get(IUserManager::class); @@ -382,11 +339,9 @@ class Filesystem { } /** - * get the default filesystem view - * - * @return View + * Get the default filesystem view */ - public static function getView() { + public static function getView(): ?View { if (!self::$defaultInstance) { /** @var IUserSession $session */ $session = \OC::$server->get(IUserSession::class); @@ -409,7 +364,7 @@ class Filesystem { /** * get the relative path of the root data directory for the current user * - * @return string + * @return ?string * * Returns path like /admin/files */ @@ -439,23 +394,12 @@ class Filesystem { * return the path to a local version of the file * we need this because we can't know if a file is stored local or not from * outside the filestorage and for some purposes a local file is needed - * - * @param string $path - * @return string */ - public static function getLocalFile($path) { + public static function getLocalFile(string $path): string|false { return self::$defaultInstance->getLocalFile($path); } /** - * @param string $path - * @return string - */ - public static function getLocalFolder($path) { - return self::$defaultInstance->getLocalFolder($path); - } - - /** * return path to file which reflects one visible in browser * * @param string $path @@ -481,7 +425,7 @@ class Filesystem { if (!$path || $path[0] !== '/') { $path = '/' . $path; } - if (strpos($path, '/../') !== false || strrchr($path, '/') === '/..') { + if (str_contains($path, '/../') || strrchr($path, '/') === '/..') { return false; } return true; @@ -584,7 +528,7 @@ class Filesystem { } /** - * @return string + * @return string|false */ public static function file_get_contents($path) { return self::$defaultInstance->file_get_contents($path); @@ -611,9 +555,10 @@ class Filesystem { } /** - * @return string + * @param string $path + * @throws \OCP\Files\InvalidPathException */ - public static function toTmpFile($path) { + public static function toTmpFile($path): string|false { return self::$defaultInstance->toTmpFile($path); } @@ -671,6 +616,7 @@ class Filesystem { * @param bool $stripTrailingSlash whether to strip the trailing slash * @param bool $isAbsolutePath whether the given path is absolute * @param bool $keepUnicode true to disable unicode normalization + * @psalm-taint-escape file * @return string */ public static function normalizePath($path, $stripTrailingSlash = true, $isAbsolutePath = false, $keepUnicode = false) { @@ -730,8 +676,8 @@ class Filesystem { * get the filesystem info * * @param string $path - * @param boolean $includeMountPoints whether to add mountpoint sizes, - * defaults to true + * @param bool|string $includeMountPoints whether to add mountpoint sizes, + * defaults to true * @return \OC\Files\FileInfo|false False if file does not exist */ public static function getFileInfo($path, $includeMountPoints = true) { @@ -787,11 +733,8 @@ class Filesystem { /** * get the ETag for a file or folder - * - * @param string $path - * @return string */ - public static function getETag($path) { + public static function getETag(string $path): string|false { return self::$defaultInstance->getETag($path); } } diff --git a/lib/private/Files/Lock/LockManager.php b/lib/private/Files/Lock/LockManager.php index e2af532a01c..978c378e506 100644 --- a/lib/private/Files/Lock/LockManager.php +++ b/lib/private/Files/Lock/LockManager.php @@ -1,5 +1,9 @@ <?php +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ namespace OC\Files\Lock; use OCP\Files\Lock\ILock; @@ -7,8 +11,11 @@ use OCP\Files\Lock\ILockManager; use OCP\Files\Lock\ILockProvider; use OCP\Files\Lock\LockContext; use OCP\PreConditionNotMetException; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\NotFoundExceptionInterface; class LockManager implements ILockManager { + private ?string $lockProviderClass = null; private ?ILockProvider $lockProvider = null; private ?LockContext $lockInScope = null; @@ -20,12 +27,34 @@ class LockManager implements ILockManager { $this->lockProvider = $lockProvider; } + public function registerLazyLockProvider(string $lockProviderClass): void { + if ($this->lockProviderClass || $this->lockProvider) { + throw new PreConditionNotMetException('There is already a registered lock provider'); + } + + $this->lockProviderClass = $lockProviderClass; + } + + private function getLockProvider(): ?ILockProvider { + if ($this->lockProvider) { + return $this->lockProvider; + } + if ($this->lockProviderClass) { + try { + $this->lockProvider = \OCP\Server::get($this->lockProviderClass); + } catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) { + } + } + + return $this->lockProvider; + } + public function isLockProviderAvailable(): bool { - return $this->lockProvider !== null; + return $this->getLockProvider() !== null; } public function runInScope(LockContext $lock, callable $callback): void { - if (!$this->lockProvider) { + if (!$this->getLockProvider()) { $callback(); return; } @@ -47,26 +76,26 @@ class LockManager implements ILockManager { } public function getLocks(int $fileId): array { - if (!$this->lockProvider) { + if (!$this->getLockProvider()) { throw new PreConditionNotMetException('No lock provider available'); } - return $this->lockProvider->getLocks($fileId); + return $this->getLockProvider()->getLocks($fileId); } public function lock(LockContext $lockInfo): ILock { - if (!$this->lockProvider) { + if (!$this->getLockProvider()) { throw new PreConditionNotMetException('No lock provider available'); } - return $this->lockProvider->lock($lockInfo); + return $this->getLockProvider()->lock($lockInfo); } public function unlock(LockContext $lockInfo): void { - if (!$this->lockProvider) { + if (!$this->getLockProvider()) { throw new PreConditionNotMetException('No lock provider available'); } - $this->lockProvider->unlock($lockInfo); + $this->getLockProvider()->unlock($lockInfo); } } diff --git a/lib/private/Files/Mount/CacheMountProvider.php b/lib/private/Files/Mount/CacheMountProvider.php index 90dfa0b05f3..27c7eec9da3 100644 --- a/lib/private/Files/Mount/CacheMountProvider.php +++ b/lib/private/Files/Mount/CacheMountProvider.php @@ -1,23 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Mount; @@ -52,7 +38,7 @@ class CacheMountProvider implements IMountProvider { * @return \OCP\Files\Mount\IMountPoint[] */ public function getMountsForUser(IUser $user, IStorageFactory $loader) { - $cacheBaseDir = $this->config->getSystemValue('cache_path', ''); + $cacheBaseDir = $this->config->getSystemValueString('cache_path', ''); if ($cacheBaseDir !== '') { $cacheDir = rtrim($cacheBaseDir, '/') . '/' . $user->getUID(); if (!file_exists($cacheDir)) { diff --git a/lib/private/Files/Mount/HomeMountPoint.php b/lib/private/Files/Mount/HomeMountPoint.php new file mode 100644 index 00000000000..5a648f08c89 --- /dev/null +++ b/lib/private/Files/Mount/HomeMountPoint.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Files\Mount; + +use OCP\Files\Storage\IStorageFactory; +use OCP\IUser; + +class HomeMountPoint extends MountPoint { + private IUser $user; + + public function __construct( + IUser $user, + $storage, + string $mountpoint, + ?array $arguments = null, + ?IStorageFactory $loader = null, + ?array $mountOptions = null, + ?int $mountId = null, + ?string $mountProvider = null, + ) { + parent::__construct($storage, $mountpoint, $arguments, $loader, $mountOptions, $mountId, $mountProvider); + $this->user = $user; + } + + public function getUser(): IUser { + return $this->user; + } +} diff --git a/lib/private/Files/Mount/LocalHomeMountProvider.php b/lib/private/Files/Mount/LocalHomeMountProvider.php index 25a67fc1574..a2b3d3b2a99 100644 --- a/lib/private/Files/Mount/LocalHomeMountProvider.php +++ b/lib/private/Files/Mount/LocalHomeMountProvider.php @@ -1,23 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Mount; @@ -38,6 +24,6 @@ class LocalHomeMountProvider implements IHomeMountProvider { */ public function getHomeMountForUser(IUser $user, IStorageFactory $loader) { $arguments = ['user' => $user]; - return new MountPoint('\OC\Files\Storage\Home', '/' . $user->getUID(), $arguments, $loader, null, null, self::class); + return new HomeMountPoint($user, '\OC\Files\Storage\Home', '/' . $user->getUID(), $arguments, $loader, null, null, self::class); } } diff --git a/lib/private/Files/Mount/Manager.php b/lib/private/Files/Mount/Manager.php index 9ba0e504058..55de488c726 100644 --- a/lib/private/Files/Mount/Manager.php +++ b/lib/private/Files/Mount/Manager.php @@ -1,38 +1,18 @@ <?php declare(strict_types=1); - /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Björn Schießle <bjoern@schiessle.org> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Files\Mount; -use OCP\Cache\CappedMemoryCache; use OC\Files\Filesystem; use OC\Files\SetupManager; use OC\Files\SetupManagerFactory; +use OCP\Cache\CappedMemoryCache; +use OCP\Files\Config\ICachedMountInfo; use OCP\Files\Mount\IMountManager; use OCP\Files\Mount\IMountPoint; use OCP\Files\NotFoundException; @@ -99,6 +79,15 @@ class Manager implements IMountManager { return $this->pathCache[$path]; } + + + if (count($this->mounts) === 0) { + $this->setupManager->setupRoot(); + if (count($this->mounts) === 0) { + throw new \Exception('No mounts even after explicitly setting up the root mounts'); + } + } + $current = $path; while (true) { $mountPoint = $current . '/'; @@ -115,7 +104,7 @@ class Manager implements IMountManager { } } - throw new NotFoundException("No mount for path " . $path . " existing mounts: " . implode(",", array_keys($this->mounts))); + throw new NotFoundException('No mount for path ' . $path . ' existing mounts (' . count($this->mounts) . '): ' . implode(',', array_keys($this->mounts))); } /** @@ -184,8 +173,13 @@ class Manager implements IMountManager { * @return IMountPoint[] */ public function findByNumericId(int $id): array { - $storageId = \OC\Files\Cache\Storage::getStorageId($id); - return $this->findByStorageId($storageId); + $result = []; + foreach ($this->mounts as $mount) { + if ($mount->getNumericStorageId() === $id) { + $result[] = $mount; + } + } + return $result; } /** @@ -221,4 +215,21 @@ class Manager implements IMountManager { }); } } + + /** + * Return the mount matching a cached mount info (or mount file info) + * + * @param ICachedMountInfo $info + * + * @return IMountPoint|null + */ + public function getMountFromMountInfo(ICachedMountInfo $info): ?IMountPoint { + $this->setupManager->setupForPath($info->getMountPoint()); + foreach ($this->mounts as $mount) { + if ($mount->getMountPoint() === $info->getMountPoint()) { + return $mount; + } + } + return null; + } } diff --git a/lib/private/Files/Mount/MountPoint.php b/lib/private/Files/Mount/MountPoint.php index 49f7e560ad3..bab2dc8e4bd 100644 --- a/lib/private/Files/Mount/MountPoint.php +++ b/lib/private/Files/Mount/MountPoint.php @@ -1,32 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Mount; @@ -44,6 +21,7 @@ class MountPoint implements IMountPoint { protected $storage = null; protected $class; protected $storageId; + protected $numericStorageId = null; protected $rootId = null; /** @@ -93,11 +71,11 @@ class MountPoint implements IMountPoint { public function __construct( $storage, string $mountpoint, - array $arguments = null, - IStorageFactory $loader = null, - array $mountOptions = null, - int $mountId = null, - string $mountProvider = null + ?array $arguments = null, + ?IStorageFactory $loader = null, + ?array $mountOptions = null, + ?int $mountId = null, + ?string $mountProvider = null, ) { if (is_null($arguments)) { $arguments = []; @@ -120,7 +98,7 @@ class MountPoint implements IMountPoint { $this->storage = $this->loader->wrap($this, $storage); } else { // Update old classes to new namespace - if (strpos($storage, 'OC_Filestorage_') !== false) { + if (str_contains($storage, 'OC_Filestorage_')) { $storage = '\OC\Files\Storage\\' . substr($storage, 15); } $this->class = $storage; @@ -195,19 +173,15 @@ class MountPoint implements IMountPoint { } /** - * @return string + * @return string|null */ public function getStorageId() { if (!$this->storageId) { - if (is_null($this->storage)) { - $storage = $this->createStorage(); //FIXME: start using exceptions - if (is_null($storage)) { - return null; - } - - $this->storage = $storage; + $storage = $this->getStorage(); + if (is_null($storage)) { + return null; } - $this->storageId = $this->storage->getId(); + $this->storageId = $storage->getId(); if (strlen($this->storageId) > 64) { $this->storageId = md5($this->storageId); } @@ -219,7 +193,14 @@ class MountPoint implements IMountPoint { * @return int */ public function getNumericStorageId() { - return $this->getStorage()->getStorageCache()->getNumericId(); + if (is_null($this->numericStorageId)) { + $storage = $this->getStorage(); + if (is_null($storage)) { + return -1; + } + $this->numericStorageId = $storage->getCache()->getNumericStorageId(); + } + return $this->numericStorageId; } /** @@ -268,7 +249,7 @@ class MountPoint implements IMountPoint { * @return mixed */ public function getOption($name, $default) { - return isset($this->mountOptions[$name]) ? $this->mountOptions[$name] : $default; + return $this->mountOptions[$name] ?? $default; } /** diff --git a/lib/private/Files/Mount/MoveableMount.php b/lib/private/Files/Mount/MoveableMount.php index a7372153d75..755733bf651 100644 --- a/lib/private/Files/Mount/MoveableMount.php +++ b/lib/private/Files/Mount/MoveableMount.php @@ -1,31 +1,18 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Mount; +use OCP\Files\Mount\IMovableMount; + /** * Defines the mount point to be (re)moved by the user */ -interface MoveableMount { +interface MoveableMount extends IMovableMount { /** * Move the mount point to $target * diff --git a/lib/private/Files/Mount/ObjectHomeMountProvider.php b/lib/private/Files/Mount/ObjectHomeMountProvider.php index 77912adfd34..4b088f2c808 100644 --- a/lib/private/Files/Mount/ObjectHomeMountProvider.php +++ b/lib/private/Files/Mount/ObjectHomeMountProvider.php @@ -1,140 +1,45 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vlastimil Pecinka <pecinka@email.cz> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Mount; +use OC\Files\ObjectStore\HomeObjectStoreStorage; +use OC\Files\ObjectStore\PrimaryObjectStoreConfig; use OCP\Files\Config\IHomeMountProvider; +use OCP\Files\Mount\IMountPoint; use OCP\Files\Storage\IStorageFactory; -use OCP\IConfig; use OCP\IUser; -use Psr\Log\LoggerInterface; /** * Mount provider for object store home storages */ class ObjectHomeMountProvider implements IHomeMountProvider { - /** - * @var IConfig - */ - private $config; - - /** - * ObjectStoreHomeMountProvider constructor. - * - * @param IConfig $config - */ - public function __construct(IConfig $config) { - $this->config = $config; + public function __construct( + private PrimaryObjectStoreConfig $objectStoreConfig, + ) { } /** - * Get the cache mount for a user + * Get the home mount for a user * * @param IUser $user * @param IStorageFactory $loader - * @return \OCP\Files\Mount\IMountPoint - */ - public function getHomeMountForUser(IUser $user, IStorageFactory $loader) { - $config = $this->getMultiBucketObjectStoreConfig($user); - if ($config === null) { - $config = $this->getSingleBucketObjectStoreConfig($user); - } - - if ($config === null) { - return null; - } - - return new MountPoint('\OC\Files\ObjectStore\HomeObjectStoreStorage', '/' . $user->getUID(), $config['arguments'], $loader, null, null, self::class); - } - - /** - * @param IUser $user - * @return array|null - */ - private function getSingleBucketObjectStoreConfig(IUser $user) { - $config = $this->config->getSystemValue('objectstore'); - if (!is_array($config)) { - return null; - } - - // sanity checks - if (empty($config['class'])) { - \OC::$server->get(LoggerInterface::class)->error('No class given for objectstore', ['app' => 'files']); - } - if (!isset($config['arguments'])) { - $config['arguments'] = []; - } - // instantiate object store implementation - $config['arguments']['objectstore'] = new $config['class']($config['arguments']); - - $config['arguments']['user'] = $user; - - return $config; - } - - /** - * @param IUser $user - * @return array|null + * @return ?IMountPoint */ - private function getMultiBucketObjectStoreConfig(IUser $user) { - $config = $this->config->getSystemValue('objectstore_multibucket'); - if (!is_array($config)) { + public function getHomeMountForUser(IUser $user, IStorageFactory $loader): ?IMountPoint { + $objectStoreConfig = $this->objectStoreConfig->getObjectStoreConfigForUser($user); + if ($objectStoreConfig === null) { return null; } + $arguments = array_merge($objectStoreConfig['arguments'], [ + 'objectstore' => $this->objectStoreConfig->buildObjectStore($objectStoreConfig), + 'user' => $user, + ]); - // sanity checks - if (empty($config['class'])) { - \OC::$server->get(LoggerInterface::class)->error('No class given for objectstore', ['app' => 'files']); - } - if (!isset($config['arguments'])) { - $config['arguments'] = []; - } - - $bucket = $this->config->getUserValue($user->getUID(), 'homeobjectstore', 'bucket', null); - - if ($bucket === null) { - /* - * Use any provided bucket argument as prefix - * and add the mapping from username => bucket - */ - if (!isset($config['arguments']['bucket'])) { - $config['arguments']['bucket'] = ''; - } - $mapper = new \OC\Files\ObjectStore\Mapper($user, $this->config); - $numBuckets = isset($config['arguments']['num_buckets']) ? $config['arguments']['num_buckets'] : 64; - $config['arguments']['bucket'] .= $mapper->getBucket($numBuckets); - - $this->config->setUserValue($user->getUID(), 'homeobjectstore', 'bucket', $config['arguments']['bucket']); - } else { - $config['arguments']['bucket'] = $bucket; - } - - // instantiate object store implementation - $config['arguments']['objectstore'] = new $config['class']($config['arguments']); - - $config['arguments']['user'] = $user; - - return $config; + return new HomeMountPoint($user, HomeObjectStoreStorage::class, '/' . $user->getUID(), $arguments, $loader, null, null, self::class); } } diff --git a/lib/private/Files/Mount/ObjectStorePreviewCacheMountProvider.php b/lib/private/Files/Mount/ObjectStorePreviewCacheMountProvider.php index 3a20afba5a5..1546ef98f50 100644 --- a/lib/private/Files/Mount/ObjectStorePreviewCacheMountProvider.php +++ b/lib/private/Files/Mount/ObjectStorePreviewCacheMountProvider.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Morris Jobke <hey@morrisjobke.de> - * - * @author Morris Jobke <hey@morrisjobke.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Mount; diff --git a/lib/private/Files/Mount/RootMountProvider.php b/lib/private/Files/Mount/RootMountProvider.php index b301fc6dd14..5e0c924ad38 100644 --- a/lib/private/Files/Mount/RootMountProvider.php +++ b/lib/private/Files/Mount/RootMountProvider.php @@ -2,102 +2,49 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Mount; use OC; use OC\Files\ObjectStore\ObjectStoreStorage; +use OC\Files\ObjectStore\PrimaryObjectStoreConfig; use OC\Files\Storage\LocalRootStorage; -use OC_App; use OCP\Files\Config\IRootMountProvider; use OCP\Files\Storage\IStorageFactory; use OCP\IConfig; -use Psr\Log\LoggerInterface; class RootMountProvider implements IRootMountProvider { + private PrimaryObjectStoreConfig $objectStoreConfig; private IConfig $config; - private LoggerInterface $logger; - public function __construct(IConfig $config, LoggerInterface $logger) { + public function __construct(PrimaryObjectStoreConfig $objectStoreConfig, IConfig $config) { + $this->objectStoreConfig = $objectStoreConfig; $this->config = $config; - $this->logger = $logger; } public function getRootMounts(IStorageFactory $loader): array { - $objectStore = $this->config->getSystemValue('objectstore', null); - $objectStoreMultiBucket = $this->config->getSystemValue('objectstore_multibucket', null); + $objectStoreConfig = $this->objectStoreConfig->getObjectStoreConfigForRoot(); - if ($objectStoreMultiBucket) { - return [$this->getMultiBucketStoreRootMount($loader, $objectStoreMultiBucket)]; - } elseif ($objectStore) { - return [$this->getObjectStoreRootMount($loader, $objectStore)]; + if ($objectStoreConfig) { + return [$this->getObjectStoreRootMount($loader, $objectStoreConfig)]; } else { return [$this->getLocalRootMount($loader)]; } } - private function validateObjectStoreConfig(array &$config) { - if (empty($config['class'])) { - $this->logger->error('No class given for objectstore', ['app' => 'files']); - } - if (!isset($config['arguments'])) { - $config['arguments'] = []; - } - - // instantiate object store implementation - $name = $config['class']; - if (strpos($name, 'OCA\\') === 0 && substr_count($name, '\\') >= 2) { - $segments = explode('\\', $name); - OC_App::loadApp(strtolower($segments[1])); - } - } - private function getLocalRootMount(IStorageFactory $loader): MountPoint { - $configDataDirectory = $this->config->getSystemValue("datadirectory", OC::$SERVERROOT . "/data"); + $configDataDirectory = $this->config->getSystemValue('datadirectory', OC::$SERVERROOT . '/data'); return new MountPoint(LocalRootStorage::class, '/', ['datadir' => $configDataDirectory], $loader, null, null, self::class); } - private function getObjectStoreRootMount(IStorageFactory $loader, array $config): MountPoint { - $this->validateObjectStoreConfig($config); - - $config['arguments']['objectstore'] = new $config['class']($config['arguments']); - // mount with plain / root object store implementation - $config['class'] = ObjectStoreStorage::class; - - return new MountPoint($config['class'], '/', $config['arguments'], $loader, null, null, self::class); - } - - private function getMultiBucketStoreRootMount(IStorageFactory $loader, array $config): MountPoint { - $this->validateObjectStoreConfig($config); - - if (!isset($config['arguments']['bucket'])) { - $config['arguments']['bucket'] = ''; - } - // put the root FS always in first bucket for multibucket configuration - $config['arguments']['bucket'] .= '0'; - - $config['arguments']['objectstore'] = new $config['class']($config['arguments']); - // mount with plain / root object store implementation - $config['class'] = ObjectStoreStorage::class; + private function getObjectStoreRootMount(IStorageFactory $loader, array $objectStoreConfig): MountPoint { + $arguments = array_merge($objectStoreConfig['arguments'], [ + 'objectstore' => $this->objectStoreConfig->buildObjectStore($objectStoreConfig), + ]); - return new MountPoint($config['class'], '/', $config['arguments'], $loader, null, null, self::class); + return new MountPoint(ObjectStoreStorage::class, '/', $arguments, $loader, null, null, self::class); } } diff --git a/lib/private/Files/Node/File.php b/lib/private/Files/Node/File.php index d8a6741dc6e..eb6411d7d13 100644 --- a/lib/private/Files/Node/File.php +++ b/lib/private/Files/Node/File.php @@ -1,30 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Node; @@ -37,7 +16,7 @@ class File extends Node implements \OCP\Files\File { * Creates a Folder that represents a non-existing path * * @param string $path path - * @return string non-existing node class + * @return NonExistingFile non-existing node */ protected function createNonExistingNode($path) { return new NonExistingFile($this->root, $this->view, $path); @@ -46,14 +25,16 @@ class File extends Node implements \OCP\Files\File { /** * @return string * @throws NotPermittedException + * @throws GenericFileException * @throws LockedException */ public function getContent() { if ($this->checkPermissions(\OCP\Constants::PERMISSION_READ)) { - /** - * @var \OC\Files\Storage\Storage $storage; - */ - return $this->view->file_get_contents($this->path); + $content = $this->view->file_get_contents($this->path); + if ($content === false) { + throw new GenericFileException(); + } + return $content; } else { throw new NotPermittedException(); } @@ -62,7 +43,7 @@ class File extends Node implements \OCP\Files\File { /** * @param string|resource $data * @throws NotPermittedException - * @throws \OCP\Files\GenericFileException + * @throws GenericFileException * @throws LockedException */ public function putContent($data) { @@ -80,7 +61,7 @@ class File extends Node implements \OCP\Files\File { /** * @param string $mode - * @return resource + * @return resource|false * @throws NotPermittedException * @throws LockedException */ diff --git a/lib/private/Files/Node/Folder.php b/lib/private/Files/Node/Folder.php index bf9ae3c148d..7453b553119 100644 --- a/lib/private/Files/Node/Folder.php +++ b/lib/private/Files/Node/Folder.php @@ -1,45 +1,23 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Node; use OC\Files\Cache\QuerySearchHelper; use OC\Files\Search\SearchBinaryOperator; -use OC\Files\Cache\Wrapper\CacheJail; use OC\Files\Search\SearchComparison; use OC\Files\Search\SearchOrder; use OC\Files\Search\SearchQuery; use OC\Files\Utils\PathHelper; +use OC\User\LazyUser; use OCP\Files\Cache\ICacheEntry; use OCP\Files\FileInfo; use OCP\Files\Mount\IMountPoint; +use OCP\Files\Node as INode; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\Files\Search\ISearchBinaryOperator; @@ -50,11 +28,14 @@ use OCP\Files\Search\ISearchQuery; use OCP\IUserManager; class Folder extends Node implements \OCP\Files\Folder { + + private ?IUserManager $userManager = null; + /** * Creates a Folder that represents a non-existing path * * @param string $path path - * @return string non-existing node class + * @return NonExistingFolder non-existing node */ protected function createNonExistingNode($path) { return new NonExistingFolder($this->root, $this->view, $path); @@ -68,7 +49,7 @@ class Folder extends Node implements \OCP\Files\Folder { public function getFullPath($path) { $path = $this->normalizePath($path); if (!$this->isValidPath($path)) { - throw new NotPermittedException('Invalid path'); + throw new NotPermittedException('Invalid path "' . $path . '"'); } return $this->path . $path; } @@ -88,7 +69,7 @@ class Folder extends Node implements \OCP\Files\Folder { * @return bool */ public function isSubNode($node) { - return strpos($node->getPath(), $this->path . '/') === 0; + return str_starts_with($node->getPath(), $this->path . '/'); } /** @@ -98,7 +79,7 @@ class Folder extends Node implements \OCP\Files\Folder { * @throws \OCP\Files\NotFoundException */ public function getDirectoryListing() { - $folderContent = $this->view->getDirectoryContent($this->path, '', $this->getFileInfo()); + $folderContent = $this->view->getDirectoryContent($this->path, '', $this->getFileInfo(false)); return array_map(function (FileInfo $info) { if ($info->getMimetype() === FileInfo::MIMETYPE_FOLDER) { @@ -109,12 +90,7 @@ class Folder extends Node implements \OCP\Files\Folder { }, $folderContent); } - /** - * @param string $path - * @param FileInfo $info - * @return File|Folder - */ - protected function createNode($path, FileInfo $info = null) { + protected function createNode(string $path, ?FileInfo $info = null, bool $infoHasSubMountsIncluded = true): INode { if (is_null($info)) { $isDir = $this->view->is_dir($path); } else { @@ -122,32 +98,21 @@ class Folder extends Node implements \OCP\Files\Folder { } $parent = dirname($path) === $this->getPath() ? $this : null; if ($isDir) { - return new Folder($this->root, $this->view, $path, $info, $parent); + return new Folder($this->root, $this->view, $path, $info, $parent, $infoHasSubMountsIncluded); } else { return new File($this->root, $this->view, $path, $info, $parent); } } - /** - * Get the node at $path - * - * @param string $path - * @return \OC\Files\Node\Node - * @throws \OCP\Files\NotFoundException - */ public function get($path) { return $this->root->get($this->getFullPath($path)); } - /** - * @param string $path - * @return bool - */ public function nodeExists($path) { try { $this->get($path); return true; - } catch (NotFoundException $e) { + } catch (NotFoundException|NotPermittedException) { return false; } } @@ -163,14 +128,27 @@ class Folder extends Node implements \OCP\Files\Folder { $nonExisting = new NonExistingFolder($this->root, $this->view, $fullPath); $this->sendHooks(['preWrite', 'preCreate'], [$nonExisting]); if (!$this->view->mkdir($fullPath)) { - throw new NotPermittedException('Could not create folder'); + // maybe another concurrent process created the folder already + if (!$this->view->is_dir($fullPath)) { + throw new NotPermittedException('Could not create folder "' . $fullPath . '"'); + } else { + // we need to ensure we don't return before the concurrent request has finished updating the cache + $tries = 5; + while (!$this->view->getFileInfo($fullPath)) { + if ($tries < 1) { + throw new NotPermittedException('Could not create folder "' . $fullPath . '", folder exists but unable to get cache entry'); + } + usleep(5 * 1000); + $tries--; + } + } } $parent = dirname($fullPath) === $this->getPath() ? $this : null; $node = new Folder($this->root, $this->view, $fullPath, null, $parent); $this->sendHooks(['postWrite', 'postCreate'], [$node]); return $node; } else { - throw new NotPermittedException('No create permission for folder'); + throw new NotPermittedException('No create permission for folder "' . $path . '"'); } } @@ -181,7 +159,7 @@ class Folder extends Node implements \OCP\Files\Folder { * @throws \OCP\Files\NotPermittedException */ public function newFile($path, $content = null) { - if (empty($path)) { + if ($path === '') { throw new NotPermittedException('Could not create as provided path is empty'); } if ($this->checkPermissions(\OCP\Constants::PERMISSION_CREATE)) { @@ -194,24 +172,24 @@ class Folder extends Node implements \OCP\Files\Folder { $result = $this->view->touch($fullPath); } if ($result === false) { - throw new NotPermittedException('Could not create path'); + throw new NotPermittedException('Could not create path "' . $fullPath . '"'); } $node = new File($this->root, $this->view, $fullPath, null, $this); $this->sendHooks(['postWrite', 'postCreate'], [$node]); return $node; } - throw new NotPermittedException('No create permission for path'); + throw new NotPermittedException('No create permission for path "' . $path . '"'); } - private function queryFromOperator(ISearchOperator $operator, string $uid = null): ISearchQuery { + private function queryFromOperator(ISearchOperator $operator, ?string $uid = null, int $limit = 0, int $offset = 0): ISearchQuery { if ($uid === null) { $user = null; } else { /** @var IUserManager $userManager */ - $userManager = \OC::$server->query(IUserManager::class); + $userManager = \OCP\Server::get(IUserManager::class); $user = $userManager->get($uid); } - return new SearchQuery($operator, 0, 0, [], $user); + return new SearchQuery($operator, $limit, $offset, [], $user); } /** @@ -230,43 +208,16 @@ class Folder extends Node implements \OCP\Files\Folder { $limitToHome = $query->limitToHome(); if ($limitToHome && count(explode('/', $this->path)) !== 3) { - throw new \InvalidArgumentException('searching by owner is only allows on the users home folder'); - } - - $rootLength = strlen($this->path); - $mount = $this->root->getMount($this->path); - $storage = $mount->getStorage(); - $internalPath = $mount->getInternalPath($this->path); - - // collect all caches for this folder, indexed by their mountpoint relative to this folder - // and save the mount which is needed later to construct the FileInfo objects - - if ($internalPath !== '') { - // a temporary CacheJail is used to handle filtering down the results to within this folder - $caches = ['' => new CacheJail($storage->getCache(''), $internalPath)]; - } else { - $caches = ['' => $storage->getCache('')]; - } - $mountByMountPoint = ['' => $mount]; - - if (!$limitToHome) { - $mounts = $this->root->getMountsIn($this->path); - foreach ($mounts as $mount) { - $storage = $mount->getStorage(); - if ($storage) { - $relativeMountPoint = ltrim(substr($mount->getMountPoint(), $rootLength), '/'); - $caches[$relativeMountPoint] = $storage->getCache(''); - $mountByMountPoint[$relativeMountPoint] = $mount; - } - } + throw new \InvalidArgumentException('searching by owner is only allowed in the users home folder'); } /** @var QuerySearchHelper $searchHelper */ $searchHelper = \OC::$server->get(QuerySearchHelper::class); + [$caches, $mountByMountPoint] = $searchHelper->getCachesAndMountPointsForSearch($this->root, $this->path, $limitToHome); $resultsPerCache = $searchHelper->searchInCaches($query, $caches); // loop through all results per-cache, constructing the FileInfo object from the CacheEntry and merge them all - $files = array_merge(...array_map(function (array $results, $relativeMountPoint) use ($mountByMountPoint) { + $files = array_merge(...array_map(function (array $results, string $relativeMountPoint) use ($mountByMountPoint) { $mount = $mountByMountPoint[$relativeMountPoint]; return array_map(function (ICacheEntry $result) use ($relativeMountPoint, $mount) { return $this->cacheEntryToFileInfo($mount, $relativeMountPoint, $result); @@ -274,9 +225,9 @@ class Folder extends Node implements \OCP\Files\Folder { }, array_values($resultsPerCache), array_keys($resultsPerCache))); // don't include this folder in the results - $files = array_filter($files, function (FileInfo $file) { + $files = array_values(array_filter($files, function (FileInfo $file) { return $file->getPath() !== $this->getPath(); - }); + })); // since results were returned per-cache, they are no longer fully sorted $order = $query->getOrder(); @@ -301,7 +252,26 @@ class Folder extends Node implements \OCP\Files\Folder { $cacheEntry['internalPath'] = $cacheEntry['path']; $cacheEntry['path'] = rtrim($appendRoot . $cacheEntry->getPath(), '/'); $subPath = $cacheEntry['path'] !== '' ? '/' . $cacheEntry['path'] : ''; - return new \OC\Files\FileInfo($this->path . $subPath, $mount->getStorage(), $cacheEntry['internalPath'], $cacheEntry, $mount); + $storage = $mount->getStorage(); + + $owner = null; + $ownerId = $storage->getOwner($cacheEntry['internalPath']); + if ($ownerId !== false) { + // Cache the user manager (for performance) + if ($this->userManager === null) { + $this->userManager = \OCP\Server::get(IUserManager::class); + } + $owner = new LazyUser($ownerId, $this->userManager); + } + + return new \OC\Files\FileInfo( + $this->path . $subPath, + $storage, + $cacheEntry['internalPath'], + $cacheEntry, + $mount, + $owner, + ); } /** @@ -311,7 +281,7 @@ class Folder extends Node implements \OCP\Files\Folder { * @return Node[] */ public function searchByMime($mimetype) { - if (strpos($mimetype, '/') === false) { + if (!str_contains($mimetype, '/')) { $query = $this->queryFromOperator(new SearchComparison(ISearchComparison::COMPARE_LIKE, 'mimetype', $mimetype . '/%')); } else { $query = $this->queryFromOperator(new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'mimetype', $mimetype)); @@ -331,15 +301,24 @@ class Folder extends Node implements \OCP\Files\Folder { return $this->search($query); } + public function searchBySystemTag(string $tagName, string $userId, int $limit = 0, int $offset = 0): array { + $query = $this->queryFromOperator(new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'systemtag', $tagName), $userId, $limit, $offset); + return $this->search($query); + } + /** * @param int $id - * @return \OC\Files\Node\Node[] + * @return \OCP\Files\Node[] */ public function getById($id) { return $this->root->getByIdInPath((int)$id, $this->getPath()); } - protected function getAppDataDirectoryName(): string { + public function getFirstNodeById(int $id): ?\OCP\Files\Node { + return $this->root->getFirstNodeByIdInPath($id, $this->getPath()); + } + + public function getAppDataDirectoryName(): string { $instanceId = \OC::$server->getConfig()->getSystemValueString('instanceid'); return 'appdata_' . $instanceId; } @@ -357,8 +336,15 @@ class Folder extends Node implements \OCP\Files\Folder { * @return array */ protected function getByIdInRootMount(int $id): array { + if (!method_exists($this->root, 'createNode')) { + // Always expected to be false. Being a method of Folder, this is + // always implemented. For it is an internal method and should not + // be exposed and made public, it is not part of an interface. + return []; + } $mount = $this->root->getMount(''); - $cacheEntry = $mount->getStorage()->getCache($this->path)->get($id); + $storage = $mount->getStorage(); + $cacheEntry = $storage?->getCache($this->path)->get($id); if (!$cacheEntry) { return []; } @@ -366,14 +352,14 @@ class Folder extends Node implements \OCP\Files\Folder { $absolutePath = '/' . ltrim($cacheEntry->getPath(), '/'); $currentPath = rtrim($this->path, '/') . '/'; - if (strpos($absolutePath, $currentPath) !== 0) { + if (!str_starts_with($absolutePath, $currentPath)) { return []; } return [$this->root->createNode( $absolutePath, new \OC\Files\FileInfo( $absolutePath, - $mount->getStorage(), + $storage, $cacheEntry->getPath(), $cacheEntry, $mount @@ -392,7 +378,7 @@ class Folder extends Node implements \OCP\Files\Folder { $nonExisting = new NonExistingFolder($this->root, $this->view, $this->path, $fileInfo); $this->sendHooks(['postDelete'], [$nonExisting]); } else { - throw new NotPermittedException('No delete permission for path'); + throw new NotPermittedException('No delete permission for path "' . $this->path . '"'); } } @@ -411,7 +397,7 @@ class Folder extends Node implements \OCP\Files\Folder { /** * @param int $limit * @param int $offset - * @return \OCP\Files\Node[] + * @return INode[] */ public function getRecent($limit, $offset = 0) { $filterOutNonEmptyFolder = new SearchBinaryOperator( @@ -439,7 +425,7 @@ class Folder extends Node implements \OCP\Files\Folder { $filterNonRecentFiles = new SearchComparison( ISearchComparison::COMPARE_GREATER_THAN, 'mtime', - strtotime("-2 week") + strtotime('-2 week') ); if ($offset === 0 && $limit <= 100) { $query = new SearchQuery( @@ -475,4 +461,12 @@ class Folder extends Node implements \OCP\Files\Folder { return $this->search($query); } + + public function verifyPath($fileName, $readonly = false): void { + $this->view->verifyPath( + $this->getPath(), + $fileName, + $readonly, + ); + } } diff --git a/lib/private/Files/Node/HookConnector.php b/lib/private/Files/Node/HookConnector.php index 149ffafd46b..1149951174c 100644 --- a/lib/private/Files/Node/HookConnector.php +++ b/lib/private/Files/Node/HookConnector.php @@ -1,25 +1,10 @@ <?php + +declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Node; @@ -27,6 +12,7 @@ use OC\Files\Filesystem; use OC\Files\View; use OCP\EventDispatcher\GenericEvent; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Exceptions\AbortedEventException; use OCP\Files\Events\Node\BeforeNodeCopiedEvent; use OCP\Files\Events\Node\BeforeNodeCreatedEvent; use OCP\Files\Events\Node\BeforeNodeDeletedEvent; @@ -43,39 +29,18 @@ use OCP\Files\Events\Node\NodeWrittenEvent; use OCP\Files\FileInfo; use OCP\Files\IRootFolder; use OCP\Util; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Psr\Log\LoggerInterface; class HookConnector { - /** @var IRootFolder */ - private $root; - - /** @var View */ - private $view; - /** @var FileInfo[] */ - private $deleteMetaCache = []; + private array $deleteMetaCache = []; - /** @var EventDispatcherInterface */ - private $legacyDispatcher; - - /** @var IEventDispatcher */ - private $dispatcher; - - /** - * HookConnector constructor. - * - * @param Root $root - * @param View $view - */ public function __construct( - IRootFolder $root, - View $view, - EventDispatcherInterface $legacyDispatcher, - IEventDispatcher $dispatcher) { - $this->root = $root; - $this->view = $view; - $this->legacyDispatcher = $legacyDispatcher; - $this->dispatcher = $dispatcher; + private IRootFolder $root, + private View $view, + private IEventDispatcher $dispatcher, + private LoggerInterface $logger, + ) { } public function viewToNode() { @@ -103,7 +68,7 @@ class HookConnector { public function write($arguments) { $node = $this->getNodeForPath($arguments['path']); $this->root->emit('\OC\Files', 'preWrite', [$node]); - $this->legacyDispatcher->dispatch('\OCP\Files::preWrite', new GenericEvent($node)); + $this->dispatcher->dispatch('\OCP\Files::preWrite', new GenericEvent($node)); $event = new BeforeNodeWrittenEvent($node); $this->dispatcher->dispatchTyped($event); @@ -112,7 +77,7 @@ class HookConnector { public function postWrite($arguments) { $node = $this->getNodeForPath($arguments['path']); $this->root->emit('\OC\Files', 'postWrite', [$node]); - $this->legacyDispatcher->dispatch('\OCP\Files::postWrite', new GenericEvent($node)); + $this->dispatcher->dispatch('\OCP\Files::postWrite', new GenericEvent($node)); $event = new NodeWrittenEvent($node); $this->dispatcher->dispatchTyped($event); @@ -121,7 +86,7 @@ class HookConnector { public function create($arguments) { $node = $this->getNodeForPath($arguments['path']); $this->root->emit('\OC\Files', 'preCreate', [$node]); - $this->legacyDispatcher->dispatch('\OCP\Files::preCreate', new GenericEvent($node)); + $this->dispatcher->dispatch('\OCP\Files::preCreate', new GenericEvent($node)); $event = new BeforeNodeCreatedEvent($node); $this->dispatcher->dispatchTyped($event); @@ -130,7 +95,7 @@ class HookConnector { public function postCreate($arguments) { $node = $this->getNodeForPath($arguments['path']); $this->root->emit('\OC\Files', 'postCreate', [$node]); - $this->legacyDispatcher->dispatch('\OCP\Files::postCreate', new GenericEvent($node)); + $this->dispatcher->dispatch('\OCP\Files::postCreate', new GenericEvent($node)); $event = new NodeCreatedEvent($node); $this->dispatcher->dispatchTyped($event); @@ -140,17 +105,22 @@ class HookConnector { $node = $this->getNodeForPath($arguments['path']); $this->deleteMetaCache[$node->getPath()] = $node->getFileInfo(); $this->root->emit('\OC\Files', 'preDelete', [$node]); - $this->legacyDispatcher->dispatch('\OCP\Files::preDelete', new GenericEvent($node)); + $this->dispatcher->dispatch('\OCP\Files::preDelete', new GenericEvent($node)); $event = new BeforeNodeDeletedEvent($node); - $this->dispatcher->dispatchTyped($event); + try { + $this->dispatcher->dispatchTyped($event); + } catch (AbortedEventException $e) { + $arguments['run'] = false; + $this->logger->warning('delete process aborted', ['exception' => $e]); + } } public function postDelete($arguments) { $node = $this->getNodeForPath($arguments['path']); unset($this->deleteMetaCache[$node->getPath()]); $this->root->emit('\OC\Files', 'postDelete', [$node]); - $this->legacyDispatcher->dispatch('\OCP\Files::postDelete', new GenericEvent($node)); + $this->dispatcher->dispatch('\OCP\Files::postDelete', new GenericEvent($node)); $event = new NodeDeletedEvent($node); $this->dispatcher->dispatchTyped($event); @@ -159,7 +129,7 @@ class HookConnector { public function touch($arguments) { $node = $this->getNodeForPath($arguments['path']); $this->root->emit('\OC\Files', 'preTouch', [$node]); - $this->legacyDispatcher->dispatch('\OCP\Files::preTouch', new GenericEvent($node)); + $this->dispatcher->dispatch('\OCP\Files::preTouch', new GenericEvent($node)); $event = new BeforeNodeTouchedEvent($node); $this->dispatcher->dispatchTyped($event); @@ -168,7 +138,7 @@ class HookConnector { public function postTouch($arguments) { $node = $this->getNodeForPath($arguments['path']); $this->root->emit('\OC\Files', 'postTouch', [$node]); - $this->legacyDispatcher->dispatch('\OCP\Files::postTouch', new GenericEvent($node)); + $this->dispatcher->dispatch('\OCP\Files::postTouch', new GenericEvent($node)); $event = new NodeTouchedEvent($node); $this->dispatcher->dispatchTyped($event); @@ -178,17 +148,22 @@ class HookConnector { $source = $this->getNodeForPath($arguments['oldpath']); $target = $this->getNodeForPath($arguments['newpath']); $this->root->emit('\OC\Files', 'preRename', [$source, $target]); - $this->legacyDispatcher->dispatch('\OCP\Files::preRename', new GenericEvent([$source, $target])); + $this->dispatcher->dispatch('\OCP\Files::preRename', new GenericEvent([$source, $target])); $event = new BeforeNodeRenamedEvent($source, $target); - $this->dispatcher->dispatchTyped($event); + try { + $this->dispatcher->dispatchTyped($event); + } catch (AbortedEventException $e) { + $arguments['run'] = false; + $this->logger->warning('rename process aborted', ['exception' => $e]); + } } public function postRename($arguments) { $source = $this->getNodeForPath($arguments['oldpath']); $target = $this->getNodeForPath($arguments['newpath']); $this->root->emit('\OC\Files', 'postRename', [$source, $target]); - $this->legacyDispatcher->dispatch('\OCP\Files::postRename', new GenericEvent([$source, $target])); + $this->dispatcher->dispatch('\OCP\Files::postRename', new GenericEvent([$source, $target])); $event = new NodeRenamedEvent($source, $target); $this->dispatcher->dispatchTyped($event); @@ -196,19 +171,24 @@ class HookConnector { public function copy($arguments) { $source = $this->getNodeForPath($arguments['oldpath']); - $target = $this->getNodeForPath($arguments['newpath']); + $target = $this->getNodeForPath($arguments['newpath'], $source instanceof Folder); $this->root->emit('\OC\Files', 'preCopy', [$source, $target]); - $this->legacyDispatcher->dispatch('\OCP\Files::preCopy', new GenericEvent([$source, $target])); + $this->dispatcher->dispatch('\OCP\Files::preCopy', new GenericEvent([$source, $target])); $event = new BeforeNodeCopiedEvent($source, $target); - $this->dispatcher->dispatchTyped($event); + try { + $this->dispatcher->dispatchTyped($event); + } catch (AbortedEventException $e) { + $arguments['run'] = false; + $this->logger->warning('copy process aborted', ['exception' => $e]); + } } public function postCopy($arguments) { $source = $this->getNodeForPath($arguments['oldpath']); $target = $this->getNodeForPath($arguments['newpath']); $this->root->emit('\OC\Files', 'postCopy', [$source, $target]); - $this->legacyDispatcher->dispatch('\OCP\Files::postCopy', new GenericEvent([$source, $target])); + $this->dispatcher->dispatch('\OCP\Files::postCopy', new GenericEvent([$source, $target])); $event = new NodeCopiedEvent($source, $target); $this->dispatcher->dispatchTyped($event); @@ -217,13 +197,13 @@ class HookConnector { public function read($arguments) { $node = $this->getNodeForPath($arguments['path']); $this->root->emit('\OC\Files', 'read', [$node]); - $this->legacyDispatcher->dispatch('\OCP\Files::read', new GenericEvent([$node])); + $this->dispatcher->dispatch('\OCP\Files::read', new GenericEvent([$node])); $event = new BeforeNodeReadEvent($node); $this->dispatcher->dispatchTyped($event); } - private function getNodeForPath($path) { + private function getNodeForPath(string $path, bool $isDir = false): Node { $info = Filesystem::getView()->getFileInfo($path); if (!$info) { $fullPath = Filesystem::getView()->getAbsolutePath($path); @@ -232,7 +212,7 @@ class HookConnector { } else { $info = null; } - if (Filesystem::is_dir($path)) { + if ($isDir || Filesystem::is_dir($path)) { return new NonExistingFolder($this->root, $this->view, $fullPath, $info); } else { return new NonExistingFile($this->root, $this->view, $fullPath, $info); diff --git a/lib/private/Files/Node/LazyFolder.php b/lib/private/Files/Node/LazyFolder.php index 1bae0f52e59..37b1efa0fad 100644 --- a/lib/private/Files/Node/LazyFolder.php +++ b/lib/private/Files/Node/LazyFolder.php @@ -1,33 +1,19 @@ <?php declare(strict_types=1); - /** - * @copyright Copyright (c) 2020 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Files\Node; +use OC\Files\Filesystem; use OC\Files\Utils\PathHelper; use OCP\Constants; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\Mount\IMountPoint; +use OCP\Files\NotPermittedException; /** * Class LazyFolder @@ -37,25 +23,35 @@ use OCP\Constants; * * @package OC\Files\Node */ -class LazyFolder implements \OCP\Files\Folder { - /** @var \Closure */ - private $folderClosure; - - /** @var LazyFolder | null */ - protected $folder = null; - +class LazyFolder implements Folder { + /** @var \Closure(): Folder */ + private \Closure $folderClosure; + protected ?Folder $folder = null; + protected IRootFolder $rootFolder; protected array $data; /** - * LazyFolder constructor. - * - * @param \Closure $folderClosure + * @param IRootFolder $rootFolder + * @param \Closure(): Folder $folderClosure + * @param array $data */ - public function __construct(\Closure $folderClosure, array $data = []) { + public function __construct(IRootFolder $rootFolder, \Closure $folderClosure, array $data = []) { + $this->rootFolder = $rootFolder; $this->folderClosure = $folderClosure; $this->data = $data; } + protected function getRootFolder(): IRootFolder { + return $this->rootFolder; + } + + protected function getRealFolder(): Folder { + if ($this->folder === null) { + $this->folder = call_user_func($this->folderClosure); + } + return $this->folder; + } + /** * Magic method to first get the real rootFolder and then * call $method with $args on it @@ -65,11 +61,7 @@ class LazyFolder implements \OCP\Files\Folder { * @return mixed */ public function __call($method, $args) { - if ($this->folder === null) { - $this->folder = call_user_func($this->folderClosure); - } - - return call_user_func_array([$this->folder, $method], $args); + return call_user_func_array([$this->getRealFolder(), $method], $args); } /** @@ -89,7 +81,7 @@ class LazyFolder implements \OCP\Files\Folder { /** * @inheritDoc */ - public function removeListener($scope = null, $method = null, callable $callback = null) { + public function removeListener($scope = null, $method = null, ?callable $callback = null) { $this->__call(__FUNCTION__, func_get_args()); } @@ -110,14 +102,14 @@ class LazyFolder implements \OCP\Files\Folder { /** * @inheritDoc */ - public function getMount($mountPoint) { + public function getMount(string $mountPoint): IMountPoint { return $this->__call(__FUNCTION__, func_get_args()); } /** - * @inheritDoc + * @return IMountPoint[] */ - public function getMountsIn($mountPoint) { + public function getMountsIn(string $mountPoint): array { return $this->__call(__FUNCTION__, func_get_args()); } @@ -142,11 +134,8 @@ class LazyFolder implements \OCP\Files\Folder { $this->__call(__FUNCTION__, func_get_args()); } - /** - * @inheritDoc - */ public function get($path) { - return $this->__call(__FUNCTION__, func_get_args()); + return $this->getRootFolder()->get($this->getFullPath($path)); } /** @@ -205,6 +194,9 @@ class LazyFolder implements \OCP\Files\Folder { * @inheritDoc */ public function getId() { + if (isset($this->data['fileid'])) { + return $this->data['fileid']; + } return $this->__call(__FUNCTION__, func_get_args()); } @@ -219,13 +211,19 @@ class LazyFolder implements \OCP\Files\Folder { * @inheritDoc */ public function getMTime() { + if (isset($this->data['mtime'])) { + return $this->data['mtime']; + } return $this->__call(__FUNCTION__, func_get_args()); } /** * @inheritDoc */ - public function getSize($includeMounts = true) { + public function getSize($includeMounts = true): int|float { + if (isset($this->data['size'])) { + return $this->data['size']; + } return $this->__call(__FUNCTION__, func_get_args()); } @@ -233,6 +231,9 @@ class LazyFolder implements \OCP\Files\Folder { * @inheritDoc */ public function getEtag() { + if (isset($this->data['etag'])) { + return $this->data['etag']; + } return $this->__call(__FUNCTION__, func_get_args()); } @@ -297,6 +298,12 @@ class LazyFolder implements \OCP\Files\Folder { * @inheritDoc */ public function getName() { + if (isset($this->data['path'])) { + return basename($this->data['path']); + } + if (isset($this->data['name'])) { + return $this->data['name']; + } return $this->__call(__FUNCTION__, func_get_args()); } @@ -388,6 +395,13 @@ class LazyFolder implements \OCP\Files\Folder { * @inheritDoc */ public function getFullPath($path) { + if (isset($this->data['path'])) { + $path = PathHelper::normalizePath($path); + if (!Filesystem::isValidPath($path)) { + throw new NotPermittedException('Invalid path "' . $path . '"'); + } + return $this->data['path'] . $path; + } return $this->__call(__FUNCTION__, func_get_args()); } @@ -405,9 +419,6 @@ class LazyFolder implements \OCP\Files\Folder { return $this->__call(__FUNCTION__, func_get_args()); } - /** - * @inheritDoc - */ public function nodeExists($path) { return $this->__call(__FUNCTION__, func_get_args()); } @@ -447,11 +458,19 @@ class LazyFolder implements \OCP\Files\Folder { return $this->__call(__FUNCTION__, func_get_args()); } + public function searchBySystemTag(string $tagName, string $userId, int $limit = 0, int $offset = 0) { + return $this->__call(__FUNCTION__, func_get_args()); + } + /** * @inheritDoc */ public function getById($id) { - return $this->__call(__FUNCTION__, func_get_args()); + return $this->getRootFolder()->getByIdInPath((int)$id, $this->getPath()); + } + + public function getFirstNodeById(int $id): ?\OCP\Files\Node { + return $this->getRootFolder()->getFirstNodeByIdInPath($id, $this->getPath()); } /** @@ -527,4 +546,23 @@ class LazyFolder implements \OCP\Files\Folder { public function getRelativePath($path) { return PathHelper::getRelativePath($this->getPath(), $path); } + + public function getParentId(): int { + if (isset($this->data['parent'])) { + return $this->data['parent']; + } + return $this->__call(__FUNCTION__, func_get_args()); + } + + /** + * @inheritDoc + * @return array<string, int|string|bool|float|string[]|int[]> + */ + public function getMetadata(): array { + return $this->data['metadata'] ?? $this->__call(__FUNCTION__, func_get_args()); + } + + public function verifyPath($fileName, $readonly = false): void { + $this->__call(__FUNCTION__, func_get_args()); + } } diff --git a/lib/private/Files/Node/LazyRoot.php b/lib/private/Files/Node/LazyRoot.php index c01b9fdbb83..bc3f3a2e80f 100644 --- a/lib/private/Files/Node/LazyRoot.php +++ b/lib/private/Files/Node/LazyRoot.php @@ -1,28 +1,17 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Node; +use OCP\Files\Cache\ICacheEntry; use OCP\Files\IRootFolder; +use OCP\Files\Mount\IMountPoint; +use OCP\Files\Node; +use OCP\Files\Node as INode; /** * Class LazyRoot @@ -33,9 +22,18 @@ use OCP\Files\IRootFolder; * @package OC\Files\Node */ class LazyRoot extends LazyFolder implements IRootFolder { - /** - * @inheritDoc - */ + public function __construct(\Closure $folderClosure, array $data = []) { + parent::__construct($this, $folderClosure, $data); + } + + protected function getRootFolder(): IRootFolder { + $folder = $this->getRealFolder(); + if (!$folder instanceof IRootFolder) { + throw new \Exception('Lazy root folder closure didn\'t return a root folder'); + } + return $folder; + } + public function getUserFolder($userId) { return $this->__call(__FUNCTION__, func_get_args()); } @@ -43,4 +41,16 @@ class LazyRoot extends LazyFolder implements IRootFolder { public function getByIdInPath(int $id, string $path) { return $this->__call(__FUNCTION__, func_get_args()); } + + public function getFirstNodeByIdInPath(int $id, string $path): ?Node { + return $this->__call(__FUNCTION__, func_get_args()); + } + + public function getNodeFromCacheEntryAndMount(ICacheEntry $cacheEntry, IMountPoint $mountPoint): INode { + return $this->getRootFolder()->getNodeFromCacheEntryAndMount($cacheEntry, $mountPoint); + } + + public function getAppDataDirectoryName(): string { + return $this->__call(__FUNCTION__, func_get_args()); + } } diff --git a/lib/private/Files/Node/LazyUserFolder.php b/lib/private/Files/Node/LazyUserFolder.php index c85a356ddd3..77479c2fa5e 100644 --- a/lib/private/Files/Node/LazyUserFolder.php +++ b/lib/private/Files/Node/LazyUserFolder.php @@ -2,68 +2,65 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Files\Node; -use OCP\Files\FileInfo; use OCP\Constants; +use OCP\Files\File; +use OCP\Files\FileInfo; +use OCP\Files\Folder; use OCP\Files\IRootFolder; +use OCP\Files\Mount\IMountManager; use OCP\Files\NotFoundException; use OCP\IUser; +use Psr\Log\LoggerInterface; class LazyUserFolder extends LazyFolder { - private IRootFolder $root; private IUser $user; private string $path; + private IMountManager $mountManager; - public function __construct(IRootFolder $rootFolder, IUser $user) { - $this->root = $rootFolder; + public function __construct(IRootFolder $rootFolder, IUser $user, IMountManager $mountManager) { $this->user = $user; + $this->mountManager = $mountManager; $this->path = '/' . $user->getUID() . '/files'; - parent::__construct(function () use ($user) { + parent::__construct($rootFolder, function () use ($user): Folder { try { - return $this->root->get('/' . $user->getUID() . '/files'); + $node = $this->getRootFolder()->get($this->path); + if ($node instanceof File) { + $e = new \RuntimeException(); + \OCP\Server::get(LoggerInterface::class)->error('User root storage is not a folder: ' . $this->path, [ + 'exception' => $e, + ]); + throw $e; + } + return $node; } catch (NotFoundException $e) { - if (!$this->root->nodeExists('/' . $user->getUID())) { - $this->root->newFolder('/' . $user->getUID()); + if (!$this->getRootFolder()->nodeExists('/' . $user->getUID())) { + $this->getRootFolder()->newFolder('/' . $user->getUID()); } - return $this->root->newFolder('/' . $user->getUID() . '/files'); + return $this->getRootFolder()->newFolder($this->path); } }, [ 'path' => $this->path, - 'permissions' => Constants::PERMISSION_ALL, + // Sharing user root folder is not allowed + 'permissions' => Constants::PERMISSION_ALL ^ Constants::PERMISSION_SHARE, 'type' => FileInfo::TYPE_FOLDER, 'mimetype' => FileInfo::MIMETYPE_FOLDER, ]); } - public function get($path) { - return $this->root->get('/' . $this->user->getUID() . '/files/' . ltrim($path, '/')); - } - - /** - * @param int $id - * @return \OC\Files\Node\Node[] - */ - public function getById($id) { - return $this->root->getByIdInPath((int)$id, $this->getPath()); + public function getMountPoint() { + if ($this->folder !== null) { + return $this->folder->getMountPoint(); + } + $mountPoint = $this->mountManager->find('/' . $this->user->getUID()); + if (is_null($mountPoint)) { + throw new \Exception('No mountpoint for user folder'); + } + return $mountPoint; } } diff --git a/lib/private/Files/Node/Node.php b/lib/private/Files/Node/Node.php index 8a752ff281d..5dbdc4054bf 100644 --- a/lib/private/Files/Node/Node.php +++ b/lib/private/Files/Node/Node.php @@ -1,70 +1,45 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bernhard Posselt <dev@bernhard-posselt.com> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Node; use OC\Files\Filesystem; use OC\Files\Mount\MoveableMount; use OC\Files\Utils\PathHelper; +use OCP\EventDispatcher\GenericEvent; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\FileInfo; use OCP\Files\InvalidPathException; +use OCP\Files\IRootFolder; +use OCP\Files\Node as INode; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\Lock\LockedException; -use Symfony\Component\EventDispatcher\GenericEvent; +use OCP\PreConditionNotMetException; -// FIXME: this class really should be abstract -class Node implements \OCP\Files\Node { +// FIXME: this class really should be abstract (+1) +class Node implements INode { /** * @var \OC\Files\View $view */ protected $view; - /** - * @var \OC\Files\Node\Root $root - */ - protected $root; + protected IRootFolder $root; /** - * @var string $path + * @var string $path Absolute path to the node (e.g. /admin/files/folder/file) */ protected $path; - /** - * @var \OCP\Files\FileInfo - */ - protected $fileInfo; + protected ?FileInfo $fileInfo; - /** - * @var Node|null - */ - protected $parent; + protected ?INode $parent; + + private bool $infoHasSubMountsIncluded; /** * @param \OC\Files\View $view @@ -72,19 +47,23 @@ class Node implements \OCP\Files\Node { * @param string $path * @param FileInfo $fileInfo */ - public function __construct($root, $view, $path, $fileInfo = null, ?Node $parent = null) { + public function __construct(IRootFolder $root, $view, $path, $fileInfo = null, ?INode $parent = null, bool $infoHasSubMountsIncluded = true) { + if (Filesystem::normalizePath($view->getRoot()) !== '/') { + throw new PreConditionNotMetException('The view passed to the node should not have any fake root set'); + } $this->view = $view; $this->root = $root; $this->path = $path; $this->fileInfo = $fileInfo; $this->parent = $parent; + $this->infoHasSubMountsIncluded = $infoHasSubMountsIncluded; } /** * Creates a Node of the same type that represents a non-existing path * * @param string $path path - * @return string non-existing node class + * @return Node non-existing node * @throws \Exception */ protected function createNonExistingNode($path) { @@ -98,17 +77,23 @@ class Node implements \OCP\Files\Node { * @throws InvalidPathException * @throws NotFoundException */ - public function getFileInfo() { + public function getFileInfo(bool $includeMountPoint = true) { if (!$this->fileInfo) { if (!Filesystem::isValidPath($this->path)) { throw new InvalidPathException(); } - $fileInfo = $this->view->getFileInfo($this->path); + $fileInfo = $this->view->getFileInfo($this->path, $includeMountPoint); + $this->infoHasSubMountsIncluded = $includeMountPoint; if ($fileInfo instanceof FileInfo) { $this->fileInfo = $fileInfo; } else { throw new NotFoundException(); } + } elseif ($includeMountPoint && !$this->infoHasSubMountsIncluded && $this instanceof Folder) { + if ($this->fileInfo instanceof \OC\Files\FileInfo) { + $this->view->addSubMounts($this->fileInfo); + } + $this->infoHasSubMountsIncluded = true; } return $this->fileInfo; } @@ -116,12 +101,22 @@ class Node implements \OCP\Files\Node { /** * @param string[] $hooks */ - protected function sendHooks($hooks, array $args = null) { + protected function sendHooks($hooks, ?array $args = null) { $args = !empty($args) ? $args : [$this]; - $dispatcher = \OC::$server->getEventDispatcher(); + /** @var IEventDispatcher $dispatcher */ + $dispatcher = \OC::$server->get(IEventDispatcher::class); foreach ($hooks as $hook) { - $this->root->emit('\OC\Files', $hook, $args); - $dispatcher->dispatch('\OCP\Files::' . $hook, new GenericEvent($args)); + if (method_exists($this->root, 'emit')) { + $this->root->emit('\OC\Files', $hook, $args); + } + + if (in_array($hook, ['preWrite', 'postWrite', 'preCreate', 'postCreate', 'preTouch', 'postTouch', 'preDelete', 'postDelete'], true)) { + $event = new GenericEvent($args[0]); + } else { + $event = new GenericEvent($args); + } + + $dispatcher->dispatch('\OCP\Files::' . $hook, $event); } } @@ -163,7 +158,7 @@ class Node implements \OCP\Files\Node { public function getStorage() { $storage = $this->getMountPoint()->getStorage(); if (!$storage) { - throw new \Exception("No storage for node"); + throw new \Exception('No storage for node'); } return $storage; } @@ -179,7 +174,7 @@ class Node implements \OCP\Files\Node { * @return string */ public function getInternalPath() { - return $this->getFileInfo()->getInternalPath(); + return $this->getFileInfo(false)->getInternalPath(); } /** @@ -188,7 +183,7 @@ class Node implements \OCP\Files\Node { * @throws NotFoundException */ public function getId() { - return $this->getFileInfo()->getId(); + return $this->getFileInfo(false)->getId() ?? -1; } /** @@ -209,11 +204,11 @@ class Node implements \OCP\Files\Node { /** * @param bool $includeMounts - * @return int + * @return int|float * @throws InvalidPathException * @throws NotFoundException */ - public function getSize($includeMounts = true) { + public function getSize($includeMounts = true): int|float { return $this->getFileInfo()->getSize($includeMounts); } @@ -232,7 +227,7 @@ class Node implements \OCP\Files\Node { * @throws NotFoundException */ public function getPermissions() { - return $this->getFileInfo()->getPermissions(); + return $this->getFileInfo(false)->getPermissions(); } /** @@ -241,7 +236,7 @@ class Node implements \OCP\Files\Node { * @throws NotFoundException */ public function isReadable() { - return $this->getFileInfo()->isReadable(); + return $this->getFileInfo(false)->isReadable(); } /** @@ -250,7 +245,7 @@ class Node implements \OCP\Files\Node { * @throws NotFoundException */ public function isUpdateable() { - return $this->getFileInfo()->isUpdateable(); + return $this->getFileInfo(false)->isUpdateable(); } /** @@ -259,7 +254,7 @@ class Node implements \OCP\Files\Node { * @throws NotFoundException */ public function isDeletable() { - return $this->getFileInfo()->isDeletable(); + return $this->getFileInfo(false)->isDeletable(); } /** @@ -268,7 +263,7 @@ class Node implements \OCP\Files\Node { * @throws NotFoundException */ public function isShareable() { - return $this->getFileInfo()->isShareable(); + return $this->getFileInfo(false)->isShareable(); } /** @@ -277,20 +272,35 @@ class Node implements \OCP\Files\Node { * @throws NotFoundException */ public function isCreatable() { - return $this->getFileInfo()->isCreatable(); + return $this->getFileInfo(false)->isCreatable(); } - /** - * @return Node - */ - public function getParent() { + public function getParent(): INode|IRootFolder { if ($this->parent === null) { $newPath = dirname($this->path); if ($newPath === '' || $newPath === '.' || $newPath === '/') { return $this->root; } - $this->parent = $this->root->get($newPath); + // Manually fetch the parent if the current node doesn't have a file info yet + try { + $fileInfo = $this->getFileInfo(); + } catch (NotFoundException) { + $this->parent = $this->root->get($newPath); + /** @var \OCP\Files\Folder $this->parent */ + return $this->parent; + } + + // gather the metadata we already know about our parent + $parentData = [ + 'path' => $newPath, + 'fileid' => $fileInfo->getParentId(), + ]; + + // and create lazy folder with it instead of always querying + $this->parent = new LazyFolder($this->root, function () use ($newPath) { + return $this->root->get($newPath); + }, $parentData); } return $this->parent; @@ -318,52 +328,46 @@ class Node implements \OCP\Files\Node { * @return bool */ public function isValidPath($path) { - if (!$path || $path[0] !== '/') { - $path = '/' . $path; - } - if (strstr($path, '/../') || strrchr($path, '/') === '/..') { - return false; - } - return true; + return Filesystem::isValidPath($path); } public function isMounted() { - return $this->getFileInfo()->isMounted(); + return $this->getFileInfo(false)->isMounted(); } public function isShared() { - return $this->getFileInfo()->isShared(); + return $this->getFileInfo(false)->isShared(); } public function getMimeType() { - return $this->getFileInfo()->getMimetype(); + return $this->getFileInfo(false)->getMimetype(); } public function getMimePart() { - return $this->getFileInfo()->getMimePart(); + return $this->getFileInfo(false)->getMimePart(); } public function getType() { - return $this->getFileInfo()->getType(); + return $this->getFileInfo(false)->getType(); } public function isEncrypted() { - return $this->getFileInfo()->isEncrypted(); + return $this->getFileInfo(false)->isEncrypted(); } public function getMountPoint() { - return $this->getFileInfo()->getMountPoint(); + return $this->getFileInfo(false)->getMountPoint(); } public function getOwner() { - return $this->getFileInfo()->getOwner(); + return $this->getFileInfo(false)->getOwner(); } public function getChecksum() { } public function getExtension(): string { - return $this->getFileInfo()->getExtension(); + return $this->getFileInfo(false)->getExtension(); } /** @@ -392,7 +396,7 @@ class Node implements \OCP\Files\Node { /** * @param string $targetPath - * @return \OC\Files\Node\Node + * @return INode * @throws InvalidPathException * @throws NotFoundException * @throws NotPermittedException if copy not allowed or failed @@ -418,7 +422,7 @@ class Node implements \OCP\Files\Node { /** * @param string $targetPath - * @return \OC\Files\Node\Node + * @return INode * @throws InvalidPathException * @throws NotFoundException * @throws NotPermittedException if move not allowed or failed @@ -428,11 +432,14 @@ class Node implements \OCP\Files\Node { $targetPath = $this->normalizePath($targetPath); $parent = $this->root->get(dirname($targetPath)); if ( - $parent instanceof Folder and - $this->isValidPath($targetPath) and - ( - $parent->isCreatable() || - ($parent->getInternalPath() === '' && $parent->getMountPoint() instanceof MoveableMount) + ($parent instanceof Folder) + && $this->isValidPath($targetPath) + && ( + $parent->isCreatable() + || ( + $parent->getInternalPath() === '' + && ($parent->getMountPoint() instanceof MoveableMount) + ) ) ) { $nonExisting = $this->createNonExistingNode($targetPath); @@ -467,4 +474,16 @@ class Node implements \OCP\Files\Node { public function getUploadTime(): int { return $this->getFileInfo()->getUploadTime(); } + + public function getParentId(): int { + return $this->fileInfo->getParentId(); + } + + /** + * @inheritDoc + * @return array<string, int|string|bool|float|string[]|int[]> + */ + public function getMetadata(): array { + return $this->fileInfo->getMetadata(); + } } diff --git a/lib/private/Files/Node/NonExistingFile.php b/lib/private/Files/Node/NonExistingFile.php index e1d706006ba..66ec2e6c040 100644 --- a/lib/private/Files/Node/NonExistingFile.php +++ b/lib/private/Files/Node/NonExistingFile.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Node; @@ -37,7 +22,7 @@ class NonExistingFile extends File { throw new NotFoundException(); } - public function copy($newPath) { + public function copy($targetPath) { throw new NotFoundException(); } @@ -53,6 +38,14 @@ class NonExistingFile extends File { } } + public function getInternalPath() { + if ($this->fileInfo) { + return parent::getInternalPath(); + } else { + return $this->getParent()->getMountPoint()->getInternalPath($this->getPath()); + } + } + public function stat() { throw new NotFoundException(); } @@ -65,7 +58,7 @@ class NonExistingFile extends File { } } - public function getSize($includeMounts = true) { + public function getSize($includeMounts = true): int|float { if ($this->fileInfo) { return parent::getSize($includeMounts); } else { diff --git a/lib/private/Files/Node/NonExistingFolder.php b/lib/private/Files/Node/NonExistingFolder.php index d99446e8ff8..4489fdaf010 100644 --- a/lib/private/Files/Node/NonExistingFolder.php +++ b/lib/private/Files/Node/NonExistingFolder.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Node; @@ -38,7 +22,7 @@ class NonExistingFolder extends Folder { throw new NotFoundException(); } - public function copy($newPath) { + public function copy($targetPath) { throw new NotFoundException(); } @@ -54,6 +38,14 @@ class NonExistingFolder extends Folder { } } + public function getInternalPath() { + if ($this->fileInfo) { + return parent::getInternalPath(); + } else { + return $this->getParent()->getMountPoint()->getInternalPath($this->getPath()); + } + } + public function stat() { throw new NotFoundException(); } @@ -66,7 +58,7 @@ class NonExistingFolder extends Folder { } } - public function getSize($includeMounts = true) { + public function getSize($includeMounts = true): int|float { if ($this->fileInfo) { return parent::getSize($includeMounts); } else { @@ -142,11 +134,11 @@ class NonExistingFolder extends Folder { throw new NotFoundException(); } - public function search($pattern) { + public function search($query) { throw new NotFoundException(); } - public function searchByMime($mime) { + public function searchByMime($mimetype) { throw new NotFoundException(); } @@ -154,10 +146,18 @@ class NonExistingFolder extends Folder { throw new NotFoundException(); } + public function searchBySystemTag(string $tagName, string $userId, int $limit = 0, int $offset = 0): array { + throw new NotFoundException(); + } + public function getById($id) { throw new NotFoundException(); } + public function getFirstNodeById(int $id): ?\OCP\Files\Node { + throw new NotFoundException(); + } + public function getFreeSpace() { throw new NotFoundException(); } diff --git a/lib/private/Files/Node/Root.php b/lib/private/Files/Node/Root.php index ca930c1002c..76afca9dee8 100644 --- a/lib/private/Files/Node/Root.php +++ b/lib/private/Files/Node/Root.php @@ -1,38 +1,12 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bernhard Posselt <dev@bernhard-posselt.com> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Stefan Weil <sw@weilnetz.de> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Files\Node; -use OCP\Cache\CappedMemoryCache; use OC\Files\FileInfo; use OC\Files\Mount\Manager; use OC\Files\Mount\MountPoint; @@ -40,15 +14,21 @@ use OC\Files\Utils\PathHelper; use OC\Files\View; use OC\Hooks\PublicEmitter; use OC\User\NoUserException; +use OCP\Cache\CappedMemoryCache; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\Cache\ICacheEntry; use OCP\Files\Config\IUserMountCache; use OCP\Files\Events\Node\FilesystemTornDownEvent; use OCP\Files\IRootFolder; use OCP\Files\Mount\IMountPoint; +use OCP\Files\Node as INode; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; +use OCP\ICache; +use OCP\ICacheFactory; use OCP\IUser; use OCP\IUserManager; +use OCP\Server; use Psr\Log\LoggerInterface; /** @@ -79,6 +59,7 @@ class Root extends Folder implements IRootFolder { private LoggerInterface $logger; private IUserManager $userManager; private IEventDispatcher $eventDispatcher; + private ICache $pathByIdCache; /** * @param Manager $manager @@ -92,7 +73,8 @@ class Root extends Folder implements IRootFolder { IUserMountCache $userMountCache, LoggerInterface $logger, IUserManager $userManager, - IEventDispatcher $eventDispatcher + IEventDispatcher $eventDispatcher, + ICacheFactory $cacheFactory, ) { parent::__construct($this, $view, ''); $this->mountManager = $manager; @@ -105,6 +87,7 @@ class Root extends Folder implements IRootFolder { $eventDispatcher->addListener(FilesystemTornDownEvent::class, function () { $this->userFolderCache = new CappedMemoryCache(); }); + $this->pathByIdCache = $cacheFactory->createLocal('path-by-id'); } /** @@ -130,7 +113,7 @@ class Root extends Folder implements IRootFolder { * @param string $method optional * @param callable $callback optional */ - public function removeListener($scope = null, $method = null, callable $callback = null) { + public function removeListener($scope = null, $method = null, ?callable $callback = null) { $this->emitter->removeListener($scope, $method, $callback); } @@ -153,11 +136,7 @@ class Root extends Folder implements IRootFolder { $this->mountManager->addMount($mount); } - /** - * @param string $mountPoint - * @return \OC\Files\Mount\MountPoint - */ - public function getMount($mountPoint) { + public function getMount(string $mountPoint): IMountPoint { return $this->mountManager->find($mountPoint); } @@ -165,7 +144,7 @@ class Root extends Folder implements IRootFolder { * @param string $mountPoint * @return \OC\Files\Mount\MountPoint[] */ - public function getMountsIn($mountPoint) { + public function getMountsIn(string $mountPoint): array { return $this->mountManager->findIn($mountPoint); } @@ -192,19 +171,13 @@ class Root extends Folder implements IRootFolder { $this->mountManager->remove($mount); } - /** - * @param string $path - * @return Node - * @throws \OCP\Files\NotPermittedException - * @throws \OCP\Files\NotFoundException - */ public function get($path) { $path = $this->normalizePath($path); if ($this->isValidPath($path)) { $fullPath = $this->getFullPath($path); - $fileInfo = $this->view->getFileInfo($fullPath); + $fileInfo = $this->view->getFileInfo($fullPath, false); if ($fileInfo) { - return $this->createNode($fullPath, $fileInfo); + return $this->createNode($fullPath, $fileInfo, false); } else { throw new NotFoundException($path); } @@ -290,9 +263,9 @@ class Root extends Folder implements IRootFolder { /** * @param bool $includeMounts - * @return int + * @return int|float */ - public function getSize($includeMounts = true) { + public function getSize($includeMounts = true): int|float { return 0; } @@ -339,10 +312,9 @@ class Root extends Folder implements IRootFolder { } /** - * @return Node * @throws \OCP\Files\NotFoundException */ - public function getParent() { + public function getParent(): INode|IRootFolder { throw new NotFoundException(); } @@ -386,7 +358,7 @@ class Root extends Folder implements IRootFolder { try { $folder = $this->get('/' . $userId . '/files'); if (!$folder instanceof \OCP\Files\Folder) { - throw new \Exception("User folder for $userId exists as a file"); + throw new \Exception("Account folder for \"$userId\" exists as a file"); } } catch (NotFoundException $e) { if (!$this->nodeExists('/' . $userId)) { @@ -395,7 +367,7 @@ class Root extends Folder implements IRootFolder { $folder = $this->newFolder('/' . $userId . '/files'); } } else { - $folder = new LazyUserFolder($this, $userObject); + $folder = new LazyUserFolder($this, $userObject, $this->mountManager); } $this->userFolderCache->set($userId, $folder); @@ -408,13 +380,42 @@ class Root extends Folder implements IRootFolder { return $this->userMountCache; } + public function getFirstNodeByIdInPath(int $id, string $path): ?INode { + // scope the cache by user, so we don't return nodes for different users + if ($this->user) { + $cachedPath = $this->pathByIdCache->get($this->user->getUID() . '::' . $id); + if ($cachedPath && str_starts_with($cachedPath, $path)) { + // getting the node by path is significantly cheaper than finding it by id + try { + $node = $this->get($cachedPath); + // by validating that the cached path still has the requested fileid we can work around the need to invalidate the cached path + // if the cached path is invalid or a different file now we fall back to the uncached logic + if ($node && $node->getId() === $id) { + return $node; + } + } catch (NotFoundException|NotPermittedException) { + // The file may be moved but the old path still in cache + } + } + } + $node = current($this->getByIdInPath($id, $path)); + if (!$node) { + return null; + } + + if ($this->user) { + $this->pathByIdCache->set($this->user->getUID() . '::' . $id, $node->getPath()); + } + return $node; + } + /** * @param int $id * @return Node[] */ public function getByIdInPath(int $id, string $path): array { $mountCache = $this->getUserMountCache(); - if (strpos($path, '/', 1) > 0) { + if ($path !== '' && strpos($path, '/', 1) > 0) { [, $user] = explode('/', $path); } else { $user = null; @@ -457,7 +458,7 @@ class Root extends Folder implements IRootFolder { if ($folder instanceof Folder) { return $folder->getByIdInRootMount($id); } else { - throw new \Exception("getByIdInPath with non folder"); + throw new \Exception('getByIdInPath with non folder'); } } return []; @@ -475,9 +476,23 @@ class Root extends Folder implements IRootFolder { $pathRelativeToMount = substr($internalPath, strlen($rootInternalPath)); $pathRelativeToMount = ltrim($pathRelativeToMount, '/'); $absolutePath = rtrim($mount->getMountPoint() . $pathRelativeToMount, '/'); + $storage = $mount->getStorage(); + if ($storage === null) { + return null; + } + $ownerId = $storage->getOwner($pathRelativeToMount); + if ($ownerId !== false) { + $owner = Server::get(IUserManager::class)->get($ownerId); + } else { + $owner = null; + } return $this->createNode($absolutePath, new FileInfo( - $absolutePath, $mount->getStorage(), $cacheEntry->getPath(), $cacheEntry, $mount, - \OC::$server->getUserManager()->get($mount->getStorage()->getOwner($pathRelativeToMount)) + $absolutePath, + $storage, + $cacheEntry->getPath(), + $cacheEntry, + $mount, + $owner, )); }, $mountsContainingFile); @@ -491,4 +506,29 @@ class Root extends Folder implements IRootFolder { }); return $folders; } + + public function getNodeFromCacheEntryAndMount(ICacheEntry $cacheEntry, IMountPoint $mountPoint): INode { + $path = $cacheEntry->getPath(); + $fullPath = $mountPoint->getMountPoint() . $path; + // todo: LazyNode? + $info = new FileInfo($fullPath, $mountPoint->getStorage(), $path, $cacheEntry, $mountPoint); + $parentPath = dirname($fullPath); + $parent = new LazyFolder($this, function () use ($parentPath) { + $parent = $this->get($parentPath); + if ($parent instanceof \OCP\Files\Folder) { + return $parent; + } else { + throw new \Exception("parent $parentPath is not a folder"); + } + }, [ + 'path' => $parentPath, + ]); + $isDir = $info->getType() === FileInfo::TYPE_FOLDER; + $view = new View(''); + if ($isDir) { + return new Folder($this, $view, $fullPath, $info, $parent); + } else { + return new File($this, $view, $fullPath, $info, $parent); + } + } } diff --git a/lib/private/Files/Notify/Change.php b/lib/private/Files/Notify/Change.php index decb0db96b1..c8eccd11ae2 100644 --- a/lib/private/Files/Notify/Change.php +++ b/lib/private/Files/Notify/Change.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Notify; diff --git a/lib/private/Files/Notify/RenameChange.php b/lib/private/Files/Notify/RenameChange.php index e581278c504..28554ceaa26 100644 --- a/lib/private/Files/Notify/RenameChange.php +++ b/lib/private/Files/Notify/RenameChange.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Notify; diff --git a/lib/private/Files/ObjectStore/AppdataPreviewObjectStoreStorage.php b/lib/private/Files/ObjectStore/AppdataPreviewObjectStoreStorage.php index 2f6db935236..aaaee044bac 100644 --- a/lib/private/Files/ObjectStore/AppdataPreviewObjectStoreStorage.php +++ b/lib/private/Files/ObjectStore/AppdataPreviewObjectStoreStorage.php @@ -3,41 +3,27 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Morris Jobke <hey@morrisjobke.de> - * - * @author Morris Jobke <hey@morrisjobke.de> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\ObjectStore; class AppdataPreviewObjectStoreStorage extends ObjectStoreStorage { - /** @var string */ - private $internalId; + private string $internalId; - public function __construct($params) { - if (!isset($params['internal-id'])) { + /** + * @param array $parameters + * @throws \Exception + */ + public function __construct(array $parameters) { + if (!isset($parameters['internal-id'])) { throw new \Exception('missing id in parameters'); } - $this->internalId = (string)$params['internal-id']; - parent::__construct($params); + $this->internalId = (string)$parameters['internal-id']; + parent::__construct($parameters); } - public function getId() { + public function getId(): string { return 'object::appdata::preview:' . $this->internalId; } } diff --git a/lib/private/Files/ObjectStore/Azure.php b/lib/private/Files/ObjectStore/Azure.php index 553f593b299..2729bb3c037 100644 --- a/lib/private/Files/ObjectStore/Azure.php +++ b/lib/private/Files/ObjectStore/Azure.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2018 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\ObjectStore; @@ -38,13 +22,13 @@ class Azure implements IObjectStore { private $blobClient = null; /** @var string|null */ private $endpoint = null; - /** @var bool */ + /** @var bool */ private $autoCreate = false; /** * @param array $parameters */ - public function __construct($parameters) { + public function __construct(array $parameters) { $this->containerName = $parameters['container']; $this->accountName = $parameters['account_name']; $this->accountKey = $parameters['account_key']; @@ -62,7 +46,7 @@ class Azure implements IObjectStore { private function getBlobClient() { if (!$this->blobClient) { $protocol = $this->endpoint ? substr($this->endpoint, 0, strpos($this->endpoint, ':')) : 'https'; - $connectionString = "DefaultEndpointsProtocol=" . $protocol . ";AccountName=" . $this->accountName . ";AccountKey=" . $this->accountKey; + $connectionString = 'DefaultEndpointsProtocol=' . $protocol . ';AccountName=' . $this->accountName . ';AccountKey=' . $this->accountKey; if ($this->endpoint) { $connectionString .= ';BlobEndpoint=' . $this->endpoint; } @@ -100,7 +84,7 @@ class Azure implements IObjectStore { return $blob->getContentStream(); } - public function writeObject($urn, $stream, string $mimetype = null) { + public function writeObject($urn, $stream, ?string $mimetype = null) { $options = new CreateBlockBlobOptions(); if ($mimetype) { $options->setContentType($mimetype); diff --git a/lib/private/Files/ObjectStore/HomeObjectStoreStorage.php b/lib/private/Files/ObjectStore/HomeObjectStoreStorage.php index 824adcc1d0e..4e2d10705fe 100644 --- a/lib/private/Files/ObjectStore/HomeObjectStoreStorage.php +++ b/lib/private/Files/ObjectStore/HomeObjectStoreStorage.php @@ -1,67 +1,42 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\ObjectStore; -use OC\User\User; +use Exception; +use OCP\Files\IHomeStorage; +use OCP\IUser; + +class HomeObjectStoreStorage extends ObjectStoreStorage implements IHomeStorage { + protected IUser $user; -class HomeObjectStoreStorage extends ObjectStoreStorage implements \OCP\Files\IHomeStorage { /** * The home user storage requires a user object to create a unique storage id - * @param array $params + * + * @param array $parameters + * @throws Exception */ - public function __construct($params) { - if (! isset($params['user']) || ! $params['user'] instanceof User) { - throw new \Exception('missing user object in parameters'); + public function __construct(array $parameters) { + if (! isset($parameters['user']) || ! $parameters['user'] instanceof IUser) { + throw new Exception('missing user object in parameters'); } - $this->user = $params['user']; - parent::__construct($params); + $this->user = $parameters['user']; + parent::__construct($parameters); } - public function getId() { + public function getId(): string { return 'object::user:' . $this->user->getUID(); } - /** - * get the owner of a path - * - * @param string $path The path to get the owner - * @return false|string uid - */ - public function getOwner($path) { - if (is_object($this->user)) { - return $this->user->getUID(); - } - return false; + public function getOwner(string $path): string|false { + return $this->user->getUID(); } - /** - * @param string $path, optional - * @return \OC\User\User - */ - public function getUser($path = null) { + public function getUser(): IUser { return $this->user; } } diff --git a/lib/private/Files/ObjectStore/InvalidObjectStoreConfigurationException.php b/lib/private/Files/ObjectStore/InvalidObjectStoreConfigurationException.php new file mode 100644 index 00000000000..369182b069d --- /dev/null +++ b/lib/private/Files/ObjectStore/InvalidObjectStoreConfigurationException.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Files\ObjectStore; + +class InvalidObjectStoreConfigurationException extends \Exception { + +} diff --git a/lib/private/Files/ObjectStore/Mapper.php b/lib/private/Files/ObjectStore/Mapper.php index ef3c6878d81..e1174a285a6 100644 --- a/lib/private/Files/ObjectStore/Mapper.php +++ b/lib/private/Files/ObjectStore/Mapper.php @@ -1,24 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\ObjectStore; diff --git a/lib/private/Files/ObjectStore/NoopScanner.php b/lib/private/Files/ObjectStore/NoopScanner.php deleted file mode 100644 index bdfc93758d4..00000000000 --- a/lib/private/Files/ObjectStore/NoopScanner.php +++ /dev/null @@ -1,81 +0,0 @@ -<?php -/** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ -namespace OC\Files\ObjectStore; - -use OC\Files\Cache\Scanner; -use OC\Files\Storage\Storage; - -class NoopScanner extends Scanner { - public function __construct(Storage $storage) { - // we don't need the storage, so do nothing here - } - - /** - * scan a single file and store it in the cache - * - * @param string $file - * @param int $reuseExisting - * @param int $parentId - * @param array|null $cacheData existing data in the cache for the file to be scanned - * @return array an array of metadata of the scanned file - */ - public function scanFile($file, $reuseExisting = 0, $parentId = -1, $cacheData = null, $lock = true, $data = null) { - return []; - } - - /** - * scan a folder and all it's children - * - * @param string $path - * @param bool $recursive - * @param int $reuse - * @return array with the meta data of the scanned file or folder - */ - public function scan($path, $recursive = self::SCAN_RECURSIVE, $reuse = -1, $lock = true) { - return []; - } - - /** - * scan all the files and folders in a folder - * - * @param string $path - * @param bool $recursive - * @param int $reuse - * @param array $folderData existing cache data for the folder to be scanned - * @return int the size of the scanned folder or -1 if the size is unknown at this stage - */ - protected function scanChildren($path, $recursive = self::SCAN_RECURSIVE, $reuse = -1, $folderId = null, $lock = true, array $data = []) { - return 0; - } - - /** - * walk over any folders that are not fully scanned yet and scan them - */ - public function backgroundScan() { - //noop - } -} diff --git a/lib/private/Files/ObjectStore/ObjectStoreScanner.php b/lib/private/Files/ObjectStore/ObjectStoreScanner.php new file mode 100644 index 00000000000..5c3992b8458 --- /dev/null +++ b/lib/private/Files/ObjectStore/ObjectStoreScanner.php @@ -0,0 +1,79 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Files\ObjectStore; + +use OC\Files\Cache\Scanner; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\Files\FileInfo; + +class ObjectStoreScanner extends Scanner { + public function scanFile($file, $reuseExisting = 0, $parentId = -1, $cacheData = null, $lock = true, $data = null) { + return null; + } + + public function scan($path, $recursive = self::SCAN_RECURSIVE, $reuse = -1, $lock = true) { + return null; + } + + protected function scanChildren(string $path, $recursive, int $reuse, int $folderId, bool $lock, int|float $oldSize, &$etagChanged = false) { + return 0; + } + + public function backgroundScan() { + $lastPath = null; + // find any path marked as unscanned and run the scanner until no more paths are unscanned (or we get stuck) + // we sort by path DESC to ensure that contents of a folder are handled before the parent folder + while (($path = $this->getIncomplete()) !== false && $path !== $lastPath) { + $this->runBackgroundScanJob(function () use ($path) { + $item = $this->cache->get($path); + if ($item && $item->getMimeType() !== FileInfo::MIMETYPE_FOLDER) { + $fh = $this->storage->fopen($path, 'r'); + if ($fh) { + $stat = fstat($fh); + if ($stat['size']) { + $this->cache->update($item->getId(), ['size' => $stat['size']]); + } + } + } + }, $path); + // FIXME: this won't proceed with the next item, needs revamping of getIncomplete() + // to make this possible + $lastPath = $path; + } + } + + /** + * Unlike the default Cache::getIncomplete this one sorts by path. + * + * This is needed since self::backgroundScan doesn't fix child entries when running on a parent folder. + * By sorting by path we ensure that we encounter the child entries first. + * + * @return false|string + * @throws \OCP\DB\Exception + */ + private function getIncomplete() { + $query = $this->connection->getQueryBuilder(); + $query->select('path') + ->from('filecache') + ->where($query->expr()->eq('storage', $query->createNamedParameter($this->cache->getNumericStorageId(), IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq('size', $query->createNamedParameter(-1, IQueryBuilder::PARAM_INT))) + ->orderBy('path', 'DESC') + ->setMaxResults(1); + + $result = $query->executeQuery(); + $path = $result->fetchOne(); + $result->closeCursor(); + + if ($path === false) { + return false; + } + + // Make sure Oracle does not continue with null for empty strings + return (string)$path; + } +} diff --git a/lib/private/Files/ObjectStore/ObjectStoreStorage.php b/lib/private/Files/ObjectStore/ObjectStoreStorage.php index d0c5bd14b38..9ab11f8a3df 100644 --- a/lib/private/Files/ObjectStore/ObjectStoreStorage.php +++ b/lib/private/Files/ObjectStore/ObjectStoreStorage.php @@ -1,105 +1,83 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Marcel Klehr <mklehr@gmx.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Tigran Mkrtchyan <tigran.mkrtchyan@desy.de> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\ObjectStore; +use Aws\S3\Exception\S3Exception; +use Aws\S3\Exception\S3MultipartUploadException; use Icewind\Streams\CallbackWrapper; use Icewind\Streams\CountWrapper; use Icewind\Streams\IteratorDirectory; use OC\Files\Cache\Cache; use OC\Files\Cache\CacheEntry; use OC\Files\Storage\PolyFill\CopyDirectory; +use OCP\Files\Cache\ICache; use OCP\Files\Cache\ICacheEntry; +use OCP\Files\Cache\IScanner; use OCP\Files\FileInfo; +use OCP\Files\GenericFileException; use OCP\Files\NotFoundException; use OCP\Files\ObjectStore\IObjectStore; +use OCP\Files\ObjectStore\IObjectStoreMetaData; +use OCP\Files\ObjectStore\IObjectStoreMultiPartUpload; +use OCP\Files\Storage\IChunkedFileWrite; use OCP\Files\Storage\IStorage; +use Psr\Log\LoggerInterface; -class ObjectStoreStorage extends \OC\Files\Storage\Common { +class ObjectStoreStorage extends \OC\Files\Storage\Common implements IChunkedFileWrite { use CopyDirectory; - /** - * @var \OCP\Files\ObjectStore\IObjectStore $objectStore - */ - protected $objectStore; - /** - * @var string $id - */ - protected $id; - /** - * @var \OC\User\User $user - */ - protected $user; + protected IObjectStore $objectStore; + protected string $id; + private string $objectPrefix = 'urn:oid:'; - private $objectPrefix = 'urn:oid:'; + private LoggerInterface $logger; - private $logger; + private bool $handleCopiesAsOwned; + protected bool $validateWrites = true; + private bool $preserveCacheItemsOnDelete = false; - /** @var bool */ - protected $validateWrites = true; - - public function __construct($params) { - if (isset($params['objectstore']) && $params['objectstore'] instanceof IObjectStore) { - $this->objectStore = $params['objectstore']; + /** + * @param array $parameters + * @throws \Exception + */ + public function __construct(array $parameters) { + if (isset($parameters['objectstore']) && $parameters['objectstore'] instanceof IObjectStore) { + $this->objectStore = $parameters['objectstore']; } else { throw new \Exception('missing IObjectStore instance'); } - if (isset($params['storageid'])) { - $this->id = 'object::store:' . $params['storageid']; + if (isset($parameters['storageid'])) { + $this->id = 'object::store:' . $parameters['storageid']; } else { $this->id = 'object::store:' . $this->objectStore->getStorageId(); } - if (isset($params['objectPrefix'])) { - $this->objectPrefix = $params['objectPrefix']; - } - if (isset($params['validateWrites'])) { - $this->validateWrites = (bool)$params['validateWrites']; + if (isset($parameters['objectPrefix'])) { + $this->objectPrefix = $parameters['objectPrefix']; } - //initialize cache with root directory in cache - if (!$this->is_dir('/')) { - $this->mkdir('/'); + if (isset($parameters['validateWrites'])) { + $this->validateWrites = (bool)$parameters['validateWrites']; } + $this->handleCopiesAsOwned = (bool)($parameters['handleCopiesAsOwned'] ?? false); - $this->logger = \OC::$server->getLogger(); + $this->logger = \OCP\Server::get(LoggerInterface::class); } - public function mkdir($path) { + public function mkdir(string $path, bool $force = false, array $metadata = []): bool { $path = $this->normalizePath($path); - - if ($this->file_exists($path)) { + if (!$force && $this->file_exists($path)) { + $this->logger->warning("Tried to create an object store folder that already exists: $path"); return false; } $mTime = time(); $data = [ 'mimetype' => 'httpd/unix-directory', - 'size' => 0, + 'size' => $metadata['size'] ?? 0, 'mtime' => $mTime, 'storage_mtime' => $mTime, 'permissions' => \OCP\Constants::PERMISSION_ALL, @@ -116,10 +94,12 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { if ($parentType === false) { if (!$this->mkdir($parent)) { // something went wrong + $this->logger->warning("Parent folder ($parent) doesn't exist and couldn't be created"); return false; } } elseif ($parentType === 'file') { // parent is a file + $this->logger->warning("Parent ($parent) is a file"); return false; } // finally create the new dir @@ -132,11 +112,7 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { } } - /** - * @param string $path - * @return string - */ - private function normalizePath($path) { + private function normalizePath(string $path): string { $path = trim($path, '/'); //FIXME why do we sometimes get a path like 'files//username'? $path = str_replace('//', '/', $path); @@ -152,95 +128,108 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { /** * Object Stores use a NoopScanner because metadata is directly stored in * the file cache and cannot really scan the filesystem. The storage passed in is not used anywhere. - * - * @param string $path - * @param \OC\Files\Storage\Storage (optional) the storage to pass to the scanner - * @return \OC\Files\ObjectStore\NoopScanner */ - public function getScanner($path = '', $storage = null) { + public function getScanner(string $path = '', ?IStorage $storage = null): IScanner { if (!$storage) { $storage = $this; } if (!isset($this->scanner)) { - $this->scanner = new NoopScanner($storage); + $this->scanner = new ObjectStoreScanner($storage); } + /** @var \OC\Files\ObjectStore\ObjectStoreScanner */ return $this->scanner; } - public function getId() { + public function getId(): string { return $this->id; } - public function rmdir($path) { + public function rmdir(string $path): bool { $path = $this->normalizePath($path); + $entry = $this->getCache()->get($path); - if (!$this->is_dir($path)) { - return false; - } - - if (!$this->rmObjects($path)) { + if (!$entry || $entry->getMimeType() !== ICacheEntry::DIRECTORY_MIMETYPE) { return false; } - $this->getCache()->remove($path); - - return true; + return $this->rmObjects($entry); } - private function rmObjects($path) { - $children = $this->getCache()->getFolderContents($path); + private function rmObjects(ICacheEntry $entry): bool { + $children = $this->getCache()->getFolderContentsById($entry->getId()); foreach ($children as $child) { - if ($child['mimetype'] === 'httpd/unix-directory') { - if (!$this->rmObjects($child['path'])) { + if ($child->getMimeType() === ICacheEntry::DIRECTORY_MIMETYPE) { + if (!$this->rmObjects($child)) { return false; } } else { - if (!$this->unlink($child['path'])) { + if (!$this->rmObject($child)) { return false; } } } + if (!$this->preserveCacheItemsOnDelete) { + $this->getCache()->remove($entry->getPath()); + } + return true; } - public function unlink($path) { + public function unlink(string $path): bool { $path = $this->normalizePath($path); - $stat = $this->stat($path); + $entry = $this->getCache()->get($path); - if ($stat && isset($stat['fileid'])) { - if ($stat['mimetype'] === 'httpd/unix-directory') { - return $this->rmdir($path); + if ($entry instanceof ICacheEntry) { + if ($entry->getMimeType() === ICacheEntry::DIRECTORY_MIMETYPE) { + return $this->rmObjects($entry); + } else { + return $this->rmObject($entry); } - try { - $this->objectStore->deleteObject($this->getURN($stat['fileid'])); - } catch (\Exception $ex) { - if ($ex->getCode() !== 404) { - $this->logger->logException($ex, [ + } + return false; + } + + public function rmObject(ICacheEntry $entry): bool { + try { + $this->objectStore->deleteObject($this->getURN($entry->getId())); + } catch (\Exception $ex) { + if ($ex->getCode() !== 404) { + $this->logger->error( + 'Could not delete object ' . $this->getURN($entry->getId()) . ' for ' . $entry->getPath(), + [ 'app' => 'objectstore', - 'message' => 'Could not delete object ' . $this->getURN($stat['fileid']) . ' for ' . $path, - ]); - return false; - } - //removing from cache is ok as it does not exist in the objectstore anyway + 'exception' => $ex, + ] + ); + return false; } - $this->getCache()->remove($path); - return true; + //removing from cache is ok as it does not exist in the objectstore anyway } - return false; + if (!$this->preserveCacheItemsOnDelete) { + $this->getCache()->remove($entry->getPath()); + } + return true; } - public function stat($path) { + public function stat(string $path): array|false { $path = $this->normalizePath($path); $cacheEntry = $this->getCache()->get($path); if ($cacheEntry instanceof CacheEntry) { return $cacheEntry->getData(); } else { + if ($path === '') { + $this->mkdir('', true); + $cacheEntry = $this->getCache()->get($path); + if ($cacheEntry instanceof CacheEntry) { + return $cacheEntry->getData(); + } + } return false; } } - public function getPermissions($path) { + public function getPermissions(string $path): int { $stat = $this->stat($path); if (is_array($stat) && isset($stat['permissions'])) { @@ -255,17 +244,13 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { * The default implementations just appends the fileId to 'urn:oid:'. Make sure the URN is unique over all users. * You may need a mapping table to store your URN if it cannot be generated from the fileid. * - * @param int $fileId the fileid - * @return null|string the unified resource name used to identify the object + * @return string the unified resource name used to identify the object */ - public function getURN($fileId) { - if (is_numeric($fileId)) { - return $this->objectPrefix . $fileId; - } - return null; + public function getURN(int $fileId): string { + return $this->objectPrefix . $fileId; } - public function opendir($path) { + public function opendir(string $path) { $path = $this->normalizePath($path); try { @@ -277,12 +262,12 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { return IteratorDirectory::wrap($files); } catch (\Exception $e) { - $this->logger->logException($e); + $this->logger->error($e->getMessage(), ['exception' => $e]); return false; } } - public function filetype($path) { + public function filetype(string $path): string|false { $path = $this->normalizePath($path); $stat = $this->stat($path); if ($stat) { @@ -295,7 +280,7 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { } } - public function fopen($path, $mode) { + public function fopen(string $path, string $mode) { $path = $this->normalizePath($path); if (strrpos($path, '.') !== false) { @@ -327,16 +312,22 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { } return $handle; } catch (NotFoundException $e) { - $this->logger->logException($e, [ - 'app' => 'objectstore', - 'message' => 'Could not get object ' . $this->getURN($stat['fileid']) . ' for file ' . $path, - ]); + $this->logger->error( + 'Could not get object ' . $this->getURN($stat['fileid']) . ' for file ' . $path, + [ + 'app' => 'objectstore', + 'exception' => $e, + ] + ); throw $e; - } catch (\Exception $ex) { - $this->logger->logException($ex, [ - 'app' => 'objectstore', - 'message' => 'Could not get object ' . $this->getURN($stat['fileid']) . ' for file ' . $path, - ]); + } catch (\Exception $e) { + $this->logger->error( + 'Could not get object ' . $this->getURN($stat['fileid']) . ' for file ' . $path, + [ + 'app' => 'objectstore', + 'exception' => $e, + ] + ); return false; } } else { @@ -347,6 +338,12 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { case 'wb': case 'w+': case 'wb+': + $dirName = dirname($path); + $parentExists = $this->is_dir($dirName); + if (!$parentExists) { + return false; + } + $tmpFile = \OC::$server->getTempManager()->getTemporaryFile($ext); $handle = fopen($tmpFile, $mode); return CallbackWrapper::wrap($handle, null, null, function () use ($path, $tmpFile) { @@ -375,12 +372,12 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { return false; } - public function file_exists($path) { + public function file_exists(string $path): bool { $path = $this->normalizePath($path); return (bool)$this->stat($path); } - public function rename($source, $target) { + public function rename(string $source, string $target): bool { $source = $this->normalizePath($source); $target = $this->normalizePath($target); $this->remove($target); @@ -389,12 +386,12 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { return true; } - public function getMimeType($path) { + public function getMimeType(string $path): string|false { $path = $this->normalizePath($path); return parent::getMimeType($path); } - public function touch($path, $mtime = null) { + public function touch(string $path, ?int $mtime = null): bool { if (is_null($mtime)) { $mtime = time(); } @@ -416,55 +413,48 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { //create a empty file, need to have at least on char to make it // work with all object storage implementations $this->file_put_contents($path, ' '); - $mimeType = \OC::$server->getMimeTypeDetector()->detectPath($path); - $stat = [ - 'etag' => $this->getETag($path), - 'mimetype' => $mimeType, - 'size' => 0, - 'mtime' => $mtime, - 'storage_mtime' => $mtime, - 'permissions' => \OCP\Constants::PERMISSION_ALL - \OCP\Constants::PERMISSION_CREATE, - ]; - $this->getCache()->put($path, $stat); } catch (\Exception $ex) { - $this->logger->logException($ex, [ - 'app' => 'objectstore', - 'message' => 'Could not create object for ' . $path, - ]); + $this->logger->error( + 'Could not create object for ' . $path, + [ + 'app' => 'objectstore', + 'exception' => $ex, + ] + ); throw $ex; } } return true; } - public function writeBack($tmpFile, $path) { + public function writeBack(string $tmpFile, string $path) { $size = filesize($tmpFile); $this->writeStream($path, fopen($tmpFile, 'r'), $size); } - /** - * external changes are not supported, exclusive access to the object storage is assumed - * - * @param string $path - * @param int $time - * @return false - */ - public function hasUpdated($path, $time) { + public function hasUpdated(string $path, int $time): bool { return false; } - public function needsPartFile() { + public function needsPartFile(): bool { return false; } - public function file_put_contents($path, $data) { - $handle = $this->fopen($path, 'w+'); - $result = fwrite($handle, $data); - fclose($handle); - return $result; + public function file_put_contents(string $path, mixed $data): int { + $fh = fopen('php://temp', 'w+'); + fwrite($fh, $data); + rewind($fh); + return $this->writeStream($path, $fh, strlen($data)); } - public function writeStream(string $path, $stream, int $size = null): int { + public function writeStream(string $path, $stream, ?int $size = null): int { + if ($size === null) { + $stats = fstat($stream); + if (is_array($stats) && isset($stats['size'])) { + $size = $stats['size']; + } + } + $stat = $this->stat($path); if (empty($stat)) { // create new file @@ -480,6 +470,14 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { $mimetypeDetector = \OC::$server->getMimeTypeDetector(); $mimetype = $mimetypeDetector->detectPath($path); + $metadata = [ + 'mimetype' => $mimetype, + 'original-storage' => $this->getId(), + 'original-path' => $path, + ]; + if ($size) { + $metadata['size'] = $size; + } $stat['mimetype'] = $mimetype; $stat['etag'] = $this->getETag($path); @@ -491,30 +489,37 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { if ($exists) { $fileId = $stat['fileid']; } else { + $parent = $this->normalizePath(dirname($path)); + if (!$this->is_dir($parent)) { + throw new \InvalidArgumentException("trying to upload a file ($path) inside a non-directory ($parent)"); + } $fileId = $this->getCache()->put($uploadPath, $stat); } $urn = $this->getURN($fileId); try { //upload to object storage - if ($size === null) { - $countStream = CountWrapper::wrap($stream, function ($writtenSize) use ($fileId, &$size) { + + $totalWritten = 0; + $countStream = CountWrapper::wrap($stream, function ($writtenSize) use ($fileId, $size, $exists, &$totalWritten) { + if (is_null($size) && !$exists) { $this->getCache()->update($fileId, [ 'size' => $writtenSize, ]); - $size = $writtenSize; - }); - $this->objectStore->writeObject($urn, $countStream, $mimetype); - if (is_resource($countStream)) { - fclose($countStream); } - $stat['size'] = $size; + $totalWritten = $writtenSize; + }); + + if ($this->objectStore instanceof IObjectStoreMetaData) { + $this->objectStore->writeObjectWithMetaData($urn, $countStream, $metadata); } else { - $this->objectStore->writeObject($urn, $stream, $mimetype); - if (is_resource($stream)) { - fclose($stream); - } + $this->objectStore->writeObject($urn, $countStream, $metadata['mimetype']); + } + if (is_resource($countStream)) { + fclose($countStream); } + + $stat['size'] = $totalWritten; } catch (\Exception $ex) { if (!$exists) { /* @@ -522,20 +527,28 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { * Else people lose access to existing files */ $this->getCache()->remove($uploadPath); - $this->logger->logException($ex, [ - 'app' => 'objectstore', - 'message' => 'Could not create object ' . $urn . ' for ' . $path, - ]); + $this->logger->error( + 'Could not create object ' . $urn . ' for ' . $path, + [ + 'app' => 'objectstore', + 'exception' => $ex, + ] + ); } else { - $this->logger->logException($ex, [ - 'app' => 'objectstore', - 'message' => 'Could not update object ' . $urn . ' for ' . $path, - ]); + $this->logger->error( + 'Could not update object ' . $urn . ' for ' . $path, + [ + 'app' => 'objectstore', + 'exception' => $ex, + ] + ); } - throw $ex; // make this bubble up + throw new GenericFileException('Error while writing stream to object store', 0, $ex); } if ($exists) { + // Always update the unencrypted size, for encryption the Encryption wrapper will update this afterwards anyways + $stat['unencrypted_size'] = $stat['size']; $this->getCache()->update($fileId, $stat); } else { if (!$this->validateWrites || $this->objectStore->objectExists($urn)) { @@ -546,14 +559,19 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { } } - return $size; + return $totalWritten; } public function getObjectStore(): IObjectStore { return $this->objectStore; } - public function copyFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath, $preserveMtime = false) { + public function copyFromStorage( + IStorage $sourceStorage, + string $sourceInternalPath, + string $targetInternalPath, + bool $preserveMtime = false, + ): bool { if ($sourceStorage->instanceOfStorage(ObjectStoreStorage::class)) { /** @var ObjectStoreStorage $sourceStorage */ if ($sourceStorage->getObjectStore()->getStorageId() === $this->getObjectStore()->getStorageId()) { @@ -566,7 +584,7 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { if (is_array($sourceEntryData) && array_key_exists('scan_permissions', $sourceEntryData)) { $sourceEntry['permissions'] = $sourceEntryData['scan_permissions']; } - $this->copyInner($sourceEntry, $targetInternalPath); + $this->copyInner($sourceStorage->getCache(), $sourceEntry, $targetInternalPath); return true; } } @@ -574,7 +592,90 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { return parent::copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); } - public function copy($source, $target) { + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath, ?ICacheEntry $sourceCacheEntry = null): bool { + $sourceCache = $sourceStorage->getCache(); + if ( + $sourceStorage->instanceOfStorage(ObjectStoreStorage::class) + && $sourceStorage->getObjectStore()->getStorageId() === $this->getObjectStore()->getStorageId() + ) { + if ($this->getCache()->get($targetInternalPath)) { + $this->unlink($targetInternalPath); + $this->getCache()->remove($targetInternalPath); + } + $this->getCache()->moveFromCache($sourceCache, $sourceInternalPath, $targetInternalPath); + // Do not import any data when source and target bucket are identical. + return true; + } + if (!$sourceCacheEntry) { + $sourceCacheEntry = $sourceCache->get($sourceInternalPath); + } + + $this->copyObjects($sourceStorage, $sourceCache, $sourceCacheEntry); + if ($sourceStorage->instanceOfStorage(ObjectStoreStorage::class)) { + /** @var ObjectStoreStorage $sourceStorage */ + $sourceStorage->setPreserveCacheOnDelete(true); + } + if ($sourceCacheEntry->getMimeType() === ICacheEntry::DIRECTORY_MIMETYPE) { + $sourceStorage->rmdir($sourceInternalPath); + } else { + $sourceStorage->unlink($sourceInternalPath); + } + if ($sourceStorage->instanceOfStorage(ObjectStoreStorage::class)) { + /** @var ObjectStoreStorage $sourceStorage */ + $sourceStorage->setPreserveCacheOnDelete(false); + } + if ($this->getCache()->get($targetInternalPath)) { + $this->unlink($targetInternalPath); + $this->getCache()->remove($targetInternalPath); + } + $this->getCache()->moveFromCache($sourceCache, $sourceInternalPath, $targetInternalPath); + + return true; + } + + /** + * Copy the object(s) of a file or folder into this storage, without touching the cache + */ + private function copyObjects(IStorage $sourceStorage, ICache $sourceCache, ICacheEntry $sourceCacheEntry) { + $copiedFiles = []; + try { + foreach ($this->getAllChildObjects($sourceCache, $sourceCacheEntry) as $file) { + $sourceStream = $sourceStorage->fopen($file->getPath(), 'r'); + if (!$sourceStream) { + throw new \Exception("Failed to open source file {$file->getPath()} ({$file->getId()})"); + } + $this->objectStore->writeObject($this->getURN($file->getId()), $sourceStream, $file->getMimeType()); + if (is_resource($sourceStream)) { + fclose($sourceStream); + } + $copiedFiles[] = $file->getId(); + } + } catch (\Exception $e) { + foreach ($copiedFiles as $fileId) { + try { + $this->objectStore->deleteObject($this->getURN($fileId)); + } catch (\Exception $e) { + // ignore + } + } + throw $e; + } + } + + /** + * @return \Iterator<ICacheEntry> + */ + private function getAllChildObjects(ICache $cache, ICacheEntry $entry): \Iterator { + if ($entry->getMimeType() === FileInfo::MIMETYPE_FOLDER) { + foreach ($cache->getFolderContentsById($entry->getId()) as $child) { + yield from $this->getAllChildObjects($cache, $child); + } + } else { + yield $entry; + } + } + + public function copy(string $source, string $target): bool { $source = $this->normalizePath($source); $target = $this->normalizePath($target); @@ -584,22 +685,22 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { throw new NotFoundException('Source object not found'); } - $this->copyInner($sourceEntry, $target); + $this->copyInner($cache, $sourceEntry, $target); return true; } - private function copyInner(ICacheEntry $sourceEntry, string $to) { + private function copyInner(ICache $sourceCache, ICacheEntry $sourceEntry, string $to) { $cache = $this->getCache(); if ($sourceEntry->getMimeType() === FileInfo::MIMETYPE_FOLDER) { if ($cache->inCache($to)) { $cache->remove($to); } - $this->mkdir($to); + $this->mkdir($to, false, ['size' => $sourceEntry->getSize()]); - foreach ($cache->getFolderContentsById($sourceEntry->getId()) as $child) { - $this->copyInner($child, $to . '/' . $child->getName()); + foreach ($sourceCache->getFolderContentsById($sourceEntry->getId()) as $child) { + $this->copyInner($sourceCache, $child, $to . '/' . $child->getName()); } } else { $this->copyFile($sourceEntry, $to); @@ -612,7 +713,7 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { $sourceUrn = $this->getURN($sourceEntry->getId()); if (!$cache instanceof Cache) { - throw new \Exception("Invalid source cache for object store copy"); + throw new \Exception('Invalid source cache for object store copy'); } $targetId = $cache->copyFromCache($cache, $sourceEntry, $to); @@ -621,10 +722,94 @@ class ObjectStoreStorage extends \OC\Files\Storage\Common { try { $this->objectStore->copyObject($sourceUrn, $targetUrn); + if ($this->handleCopiesAsOwned) { + // Copied the file thus we gain all permissions as we are the owner now ! warning while this aligns with local storage it should not be used and instead fix local storage ! + $cache->update($targetId, ['permissions' => \OCP\Constants::PERMISSION_ALL]); + } } catch (\Exception $e) { $cache->remove($to); throw $e; } } + + public function startChunkedWrite(string $targetPath): string { + if (!$this->objectStore instanceof IObjectStoreMultiPartUpload) { + throw new GenericFileException('Object store does not support multipart upload'); + } + $cacheEntry = $this->getCache()->get($targetPath); + $urn = $this->getURN($cacheEntry->getId()); + return $this->objectStore->initiateMultipartUpload($urn); + } + + /** + * @throws GenericFileException + */ + public function putChunkedWritePart( + string $targetPath, + string $writeToken, + string $chunkId, + $data, + $size = null, + ): ?array { + if (!$this->objectStore instanceof IObjectStoreMultiPartUpload) { + throw new GenericFileException('Object store does not support multipart upload'); + } + $cacheEntry = $this->getCache()->get($targetPath); + $urn = $this->getURN($cacheEntry->getId()); + + $result = $this->objectStore->uploadMultipartPart($urn, $writeToken, (int)$chunkId, $data, $size); + + $parts[$chunkId] = [ + 'PartNumber' => $chunkId, + 'ETag' => trim($result->get('ETag'), '"'), + ]; + return $parts[$chunkId]; + } + + public function completeChunkedWrite(string $targetPath, string $writeToken): int { + if (!$this->objectStore instanceof IObjectStoreMultiPartUpload) { + throw new GenericFileException('Object store does not support multipart upload'); + } + $cacheEntry = $this->getCache()->get($targetPath); + $urn = $this->getURN($cacheEntry->getId()); + $parts = $this->objectStore->getMultipartUploads($urn, $writeToken); + $sortedParts = array_values($parts); + sort($sortedParts); + try { + $size = $this->objectStore->completeMultipartUpload($urn, $writeToken, $sortedParts); + $stat = $this->stat($targetPath); + $mtime = time(); + if (is_array($stat)) { + $stat['size'] = $size; + $stat['mtime'] = $mtime; + $stat['mimetype'] = $this->getMimeType($targetPath); + $this->getCache()->update($stat['fileid'], $stat); + } + } catch (S3MultipartUploadException|S3Exception $e) { + $this->objectStore->abortMultipartUpload($urn, $writeToken); + $this->logger->error( + 'Could not compete multipart upload ' . $urn . ' with uploadId ' . $writeToken, + [ + 'app' => 'objectstore', + 'exception' => $e, + ] + ); + throw new GenericFileException('Could not write chunked file'); + } + return $size; + } + + public function cancelChunkedWrite(string $targetPath, string $writeToken): void { + if (!$this->objectStore instanceof IObjectStoreMultiPartUpload) { + throw new GenericFileException('Object store does not support multipart upload'); + } + $cacheEntry = $this->getCache()->get($targetPath); + $urn = $this->getURN($cacheEntry->getId()); + $this->objectStore->abortMultipartUpload($urn, $writeToken); + } + + public function setPreserveCacheOnDelete(bool $preserve) { + $this->preserveCacheItemsOnDelete = $preserve; + } } diff --git a/lib/private/Files/ObjectStore/PrimaryObjectStoreConfig.php b/lib/private/Files/ObjectStore/PrimaryObjectStoreConfig.php new file mode 100644 index 00000000000..ffc33687340 --- /dev/null +++ b/lib/private/Files/ObjectStore/PrimaryObjectStoreConfig.php @@ -0,0 +1,225 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OC\Files\ObjectStore; + +use OCP\App\IAppManager; +use OCP\Files\ObjectStore\IObjectStore; +use OCP\IConfig; +use OCP\IUser; + +/** + * @psalm-type ObjectStoreConfig array{class: class-string<IObjectStore>, arguments: array{multibucket: bool, ...}} + */ +class PrimaryObjectStoreConfig { + public function __construct( + private readonly IConfig $config, + private readonly IAppManager $appManager, + ) { + } + + /** + * @param ObjectStoreConfig $config + */ + public function buildObjectStore(array $config): IObjectStore { + return new $config['class']($config['arguments']); + } + + /** + * @return ?ObjectStoreConfig + */ + public function getObjectStoreConfigForRoot(): ?array { + if (!$this->hasObjectStore()) { + return null; + } + + $config = $this->getObjectStoreConfiguration('root'); + + if ($config['arguments']['multibucket']) { + if (!isset($config['arguments']['bucket'])) { + $config['arguments']['bucket'] = ''; + } + + // put the root FS always in first bucket for multibucket configuration + $config['arguments']['bucket'] .= '0'; + } + return $config; + } + + /** + * @return ?ObjectStoreConfig + */ + public function getObjectStoreConfigForUser(IUser $user): ?array { + if (!$this->hasObjectStore()) { + return null; + } + + $store = $this->getObjectStoreForUser($user); + $config = $this->getObjectStoreConfiguration($store); + + if ($config['arguments']['multibucket']) { + $config['arguments']['bucket'] = $this->getBucketForUser($user, $config); + } + return $config; + } + + /** + * @param string $name + * @return ObjectStoreConfig + */ + public function getObjectStoreConfiguration(string $name): array { + $configs = $this->getObjectStoreConfigs(); + $name = $this->resolveAlias($name); + if (!isset($configs[$name])) { + throw new \Exception("Object store configuration for '$name' not found"); + } + if (is_string($configs[$name])) { + throw new \Exception("Object store configuration for '{$configs[$name]}' not found"); + } + return $configs[$name]; + } + + public function resolveAlias(string $name): string { + $configs = $this->getObjectStoreConfigs(); + + while (isset($configs[$name]) && is_string($configs[$name])) { + $name = $configs[$name]; + } + return $name; + } + + public function hasObjectStore(): bool { + $objectStore = $this->config->getSystemValue('objectstore', null); + $objectStoreMultiBucket = $this->config->getSystemValue('objectstore_multibucket', null); + return $objectStore || $objectStoreMultiBucket; + } + + public function hasMultipleObjectStorages(): bool { + $objectStore = $this->config->getSystemValue('objectstore', []); + return isset($objectStore['default']); + } + + /** + * @return ?array<string, ObjectStoreConfig|string> + * @throws InvalidObjectStoreConfigurationException + */ + public function getObjectStoreConfigs(): ?array { + $objectStore = $this->config->getSystemValue('objectstore', null); + $objectStoreMultiBucket = $this->config->getSystemValue('objectstore_multibucket', null); + + // new-style multibucket config uses the same 'objectstore' key but sets `'multibucket' => true`, transparently upgrade older style config + if ($objectStoreMultiBucket) { + $objectStoreMultiBucket['arguments']['multibucket'] = true; + return [ + 'default' => 'server1', + 'server1' => $this->validateObjectStoreConfig($objectStoreMultiBucket), + 'root' => 'server1', + ]; + } elseif ($objectStore) { + if (!isset($objectStore['default'])) { + $objectStore = [ + 'default' => 'server1', + 'root' => 'server1', + 'server1' => $objectStore, + ]; + } + if (!isset($objectStore['root'])) { + $objectStore['root'] = 'default'; + } + + if (!is_string($objectStore['default'])) { + throw new InvalidObjectStoreConfigurationException('The \'default\' object storage configuration is required to be a reference to another configuration.'); + } + return array_map($this->validateObjectStoreConfig(...), $objectStore); + } else { + return null; + } + } + + /** + * @param array|string $config + * @return string|ObjectStoreConfig + */ + private function validateObjectStoreConfig(array|string $config): array|string { + if (is_string($config)) { + return $config; + } + if (!isset($config['class'])) { + throw new InvalidObjectStoreConfigurationException('No class configured for object store'); + } + if (!isset($config['arguments'])) { + $config['arguments'] = []; + } + $class = $config['class']; + $arguments = $config['arguments']; + if (!is_array($arguments)) { + throw new InvalidObjectStoreConfigurationException('Configured object store arguments are not an array'); + } + if (!isset($arguments['multibucket'])) { + $arguments['multibucket'] = false; + } + if (!is_bool($arguments['multibucket'])) { + throw new InvalidObjectStoreConfigurationException('arguments.multibucket must be a boolean in object store configuration'); + } + + if (!is_string($class)) { + throw new InvalidObjectStoreConfigurationException('Configured class for object store is not a string'); + } + + if (str_starts_with($class, 'OCA\\') && substr_count($class, '\\') >= 2) { + [$appId] = explode('\\', $class); + $this->appManager->loadApp(strtolower($appId)); + } + + if (!is_a($class, IObjectStore::class, true)) { + throw new InvalidObjectStoreConfigurationException('Configured class for object store is not an object store'); + } + return [ + 'class' => $class, + 'arguments' => $arguments, + ]; + } + + public function getBucketForUser(IUser $user, array $config): string { + $bucket = $this->getSetBucketForUser($user); + + if ($bucket === null) { + /* + * Use any provided bucket argument as prefix + * and add the mapping from username => bucket + */ + if (!isset($config['arguments']['bucket'])) { + $config['arguments']['bucket'] = ''; + } + $mapper = new Mapper($user, $this->config); + $numBuckets = $config['arguments']['num_buckets'] ?? 64; + $bucket = $config['arguments']['bucket'] . $mapper->getBucket($numBuckets); + + $this->config->setUserValue($user->getUID(), 'homeobjectstore', 'bucket', $bucket); + } + + return $bucket; + } + + public function getSetBucketForUser(IUser $user): ?string { + return $this->config->getUserValue($user->getUID(), 'homeobjectstore', 'bucket', null); + } + + public function getObjectStoreForUser(IUser $user): string { + if ($this->hasMultipleObjectStorages()) { + $value = $this->config->getUserValue($user->getUID(), 'homeobjectstore', 'objectstore', null); + if ($value === null) { + $value = $this->resolveAlias('default'); + $this->config->setUserValue($user->getUID(), 'homeobjectstore', 'objectstore', $value); + } + return $value; + } else { + return 'default'; + } + } +} diff --git a/lib/private/Files/ObjectStore/S3.php b/lib/private/Files/ObjectStore/S3.php index 6492145fb63..72e1751e23d 100644 --- a/lib/private/Files/ObjectStore/S3.php +++ b/lib/private/Files/ObjectStore/S3.php @@ -1,35 +1,23 @@ <?php + /** - * @copyright Copyright (c) 2016 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ + namespace OC\Files\ObjectStore; +use Aws\Result; +use Exception; use OCP\Files\ObjectStore\IObjectStore; +use OCP\Files\ObjectStore\IObjectStoreMetaData; +use OCP\Files\ObjectStore\IObjectStoreMultiPartUpload; -class S3 implements IObjectStore { +class S3 implements IObjectStore, IObjectStoreMultiPartUpload, IObjectStoreMetaData { use S3ConnectionTrait; use S3ObjectTrait; - public function __construct($parameters) { + public function __construct(array $parameters) { $parameters['primary_storage'] = true; $this->parseParams($parameters); } @@ -41,4 +29,114 @@ class S3 implements IObjectStore { public function getStorageId() { return $this->id; } + + public function initiateMultipartUpload(string $urn): string { + $upload = $this->getConnection()->createMultipartUpload([ + 'Bucket' => $this->bucket, + 'Key' => $urn, + ] + $this->getSSECParameters()); + $uploadId = $upload->get('UploadId'); + if ($uploadId === null) { + throw new Exception('No upload id returned'); + } + return (string)$uploadId; + } + + public function uploadMultipartPart(string $urn, string $uploadId, int $partId, $stream, $size): Result { + return $this->getConnection()->uploadPart([ + 'Body' => $stream, + 'Bucket' => $this->bucket, + 'Key' => $urn, + 'ContentLength' => $size, + 'PartNumber' => $partId, + 'UploadId' => $uploadId, + ] + $this->getSSECParameters()); + } + + public function getMultipartUploads(string $urn, string $uploadId): array { + $parts = []; + $isTruncated = true; + $partNumberMarker = 0; + + while ($isTruncated) { + $result = $this->getConnection()->listParts([ + 'Bucket' => $this->bucket, + 'Key' => $urn, + 'UploadId' => $uploadId, + 'MaxParts' => 1000, + 'PartNumberMarker' => $partNumberMarker, + ] + $this->getSSECParameters()); + $parts = array_merge($parts, $result->get('Parts') ?? []); + $isTruncated = $result->get('IsTruncated'); + $partNumberMarker = $result->get('NextPartNumberMarker'); + } + + return $parts; + } + + public function completeMultipartUpload(string $urn, string $uploadId, array $result): int { + $this->getConnection()->completeMultipartUpload([ + 'Bucket' => $this->bucket, + 'Key' => $urn, + 'UploadId' => $uploadId, + 'MultipartUpload' => ['Parts' => $result], + ] + $this->getSSECParameters()); + $stat = $this->getConnection()->headObject([ + 'Bucket' => $this->bucket, + 'Key' => $urn, + ] + $this->getSSECParameters()); + return (int)$stat->get('ContentLength'); + } + + public function abortMultipartUpload($urn, $uploadId): void { + $this->getConnection()->abortMultipartUpload([ + 'Bucket' => $this->bucket, + 'Key' => $urn, + 'UploadId' => $uploadId, + ]); + } + + private function parseS3Metadata(array $metadata): array { + $result = []; + foreach ($metadata as $key => $value) { + if (str_starts_with($key, 'x-amz-meta-')) { + $result[substr($key, strlen('x-amz-meta-'))] = $value; + } + } + return $result; + } + + public function getObjectMetaData(string $urn): array { + $object = $this->getConnection()->headObject([ + 'Bucket' => $this->bucket, + 'Key' => $urn + ] + $this->getSSECParameters())->toArray(); + return [ + 'mtime' => $object['LastModified'], + 'etag' => trim($object['ETag'], '"'), + 'size' => (int)($object['Size'] ?? $object['ContentLength']), + ] + $this->parseS3Metadata($object['Metadata'] ?? []); + } + + public function listObjects(string $prefix = ''): \Iterator { + $results = $this->getConnection()->getPaginator('ListObjectsV2', [ + 'Bucket' => $this->bucket, + 'Prefix' => $prefix, + ] + $this->getSSECParameters()); + + foreach ($results as $result) { + if (is_array($result['Contents'])) { + foreach ($result['Contents'] as $object) { + yield [ + 'urn' => basename($object['Key']), + 'metadata' => [ + 'mtime' => $object['LastModified'], + 'etag' => trim($object['ETag'], '"'), + 'size' => (int)($object['Size'] ?? $object['ContentLength']), + ], + ]; + } + } + } + } } diff --git a/lib/private/Files/ObjectStore/S3ConfigTrait.php b/lib/private/Files/ObjectStore/S3ConfigTrait.php new file mode 100644 index 00000000000..5b086db8f77 --- /dev/null +++ b/lib/private/Files/ObjectStore/S3ConfigTrait.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files\ObjectStore; + +/** + * Shared configuration between ConnectionTrait and ObjectTrait to ensure both to be in sync + */ +trait S3ConfigTrait { + protected array $params; + + protected string $bucket; + + /** Maximum number of concurrent multipart uploads */ + protected int $concurrency; + + /** Timeout, in seconds, for the connection to S3 server, not for the + * request. */ + protected float $connectTimeout; + + protected int $timeout; + + protected string|false $proxy; + + protected string $storageClass; + + /** @var int Part size in bytes (float is added for 32bit support) */ + protected int|float $uploadPartSize; + + /** @var int Limit on PUT in bytes (float is added for 32bit support) */ + private int|float $putSizeLimit; + + /** @var int Limit on COPY in bytes (float is added for 32bit support) */ + private int|float $copySizeLimit; + + private bool $useMultipartCopy = true; +} diff --git a/lib/private/Files/ObjectStore/S3ConnectionTrait.php b/lib/private/Files/ObjectStore/S3ConnectionTrait.php index deb03571c76..67b82a44ab7 100644 --- a/lib/private/Files/ObjectStore/S3ConnectionTrait.php +++ b/lib/private/Files/ObjectStore/S3ConnectionTrait.php @@ -1,35 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016 Robin Appelman <robin@icewind.nl> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Florent <florent@coppint.com> - * @author James Letendre <James.Letendre@gmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author S. Cat <33800996+sparrowjack63@users.noreply.github.com> - * @author Stephen Cuppett <steve@cuppett.com> - * @author Jasper Weyne <jasperweyne@gmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Files\ObjectStore; use Aws\ClientResolver; @@ -38,61 +12,53 @@ use Aws\Credentials\Credentials; use Aws\Exception\CredentialsException; use Aws\S3\Exception\S3Exception; use Aws\S3\S3Client; -use GuzzleHttp\Promise; +use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\RejectedPromise; +use OCP\Files\StorageNotAvailableException; use OCP\ICertificateManager; +use OCP\Server; use Psr\Log\LoggerInterface; trait S3ConnectionTrait { - /** @var array */ - protected $params; - - /** @var S3Client */ - protected $connection; - - /** @var string */ - protected $id; - - /** @var string */ - protected $bucket; + use S3ConfigTrait; - /** @var int */ - protected $timeout; + protected string $id; - /** @var string */ - protected $proxy; + protected bool $test; - /** @var string */ - protected $storageClass; - - /** @var int */ - protected $uploadPartSize; - - /** @var int */ - private $putSizeLimit; - - protected $test; + protected ?S3Client $connection = null; protected function parseParams($params) { if (empty($params['bucket'])) { - throw new \Exception("Bucket has to be configured."); + throw new \Exception('Bucket has to be configured.'); } $this->id = 'amazon::' . $params['bucket']; $this->test = isset($params['test']); $this->bucket = $params['bucket']; + // Default to 5 like the S3 SDK does + $this->concurrency = $params['concurrency'] ?? 5; $this->proxy = $params['proxy'] ?? false; + $this->connectTimeout = $params['connect_timeout'] ?? 5; $this->timeout = $params['timeout'] ?? 15; $this->storageClass = !empty($params['storageClass']) ? $params['storageClass'] : 'STANDARD'; $this->uploadPartSize = $params['uploadPartSize'] ?? 524288000; $this->putSizeLimit = $params['putSizeLimit'] ?? 104857600; + $this->copySizeLimit = $params['copySizeLimit'] ?? 5242880000; + $this->useMultipartCopy = (bool)($params['useMultipartCopy'] ?? true); $params['region'] = empty($params['region']) ? 'eu-west-1' : $params['region']; $params['hostname'] = empty($params['hostname']) ? 's3.' . $params['region'] . '.amazonaws.com' : $params['hostname']; + $params['s3-accelerate'] = $params['hostname'] === 's3-accelerate.amazonaws.com' || $params['hostname'] === 's3-accelerate.dualstack.amazonaws.com'; if (!isset($params['port']) || $params['port'] === '') { $params['port'] = (isset($params['use_ssl']) && $params['use_ssl'] === false) ? 80 : 443; } - $params['verify_bucket_exists'] = empty($params['verify_bucket_exists']) ? true : $params['verify_bucket_exists']; + $params['verify_bucket_exists'] = $params['verify_bucket_exists'] ?? true; + + if ($params['s3-accelerate']) { + $params['verify_bucket_exists'] = false; + } + $this->params = $params; } @@ -111,7 +77,7 @@ trait S3ConnectionTrait { * @throws \Exception if connection could not be made */ public function getConnection() { - if (!is_null($this->connection)) { + if ($this->connection !== null) { return $this->connection; } @@ -128,7 +94,7 @@ trait S3ConnectionTrait { ); $options = [ - 'version' => isset($this->params['version']) ? $this->params['version'] : 'latest', + 'version' => $this->params['version'] ?? 'latest', 'credentials' => $provider, 'endpoint' => $base_url, 'region' => $this->params['region'], @@ -136,9 +102,23 @@ trait S3ConnectionTrait { 'signature_provider' => \Aws\or_chain([self::class, 'legacySignatureProvider'], ClientResolver::_default_signature_provider()), 'csm' => false, 'use_arn_region' => false, - 'http' => ['verify' => $this->getCertificateBundlePath()], + 'http' => [ + 'verify' => $this->getCertificateBundlePath(), + 'connect_timeout' => $this->connectTimeout, + ], 'use_aws_shared_config_files' => false, + 'retries' => [ + 'mode' => 'standard', + 'max_attempts' => 5, + ], ]; + + if ($this->params['s3-accelerate']) { + $options['use_accelerate_endpoint'] = true; + } else { + $options['endpoint'] = $base_url; + } + if ($this->getProxy()) { $options['http']['proxy'] = $this->getProxy(); } @@ -147,33 +127,38 @@ trait S3ConnectionTrait { } $this->connection = new S3Client($options); - if (!$this->connection::isBucketDnsCompatible($this->bucket)) { - $logger = \OC::$server->get(LoggerInterface::class); - $logger->debug('Bucket "' . $this->bucket . '" This bucket name is not dns compatible, it may contain invalid characters.', - ['app' => 'objectstore']); - } + try { + $logger = Server::get(LoggerInterface::class); + if (!$this->connection::isBucketDnsCompatible($this->bucket)) { + $logger->debug('Bucket "' . $this->bucket . '" This bucket name is not dns compatible, it may contain invalid characters.', + ['app' => 'objectstore']); + } - if ($this->params['verify_bucket_exists'] && !$this->connection->doesBucketExist($this->bucket)) { - $logger = \OC::$server->get(LoggerInterface::class); - try { - $logger->info('Bucket "' . $this->bucket . '" does not exist - creating it.', ['app' => 'objectstore']); - if (!$this->connection::isBucketDnsCompatible($this->bucket)) { - throw new \Exception("The bucket will not be created because the name is not dns compatible, please correct it: " . $this->bucket); + if ($this->params['verify_bucket_exists'] && !$this->connection->doesBucketExist($this->bucket)) { + try { + $logger->info('Bucket "' . $this->bucket . '" does not exist - creating it.', ['app' => 'objectstore']); + if (!$this->connection::isBucketDnsCompatible($this->bucket)) { + throw new StorageNotAvailableException('The bucket will not be created because the name is not dns compatible, please correct it: ' . $this->bucket); + } + $this->connection->createBucket(['Bucket' => $this->bucket]); + $this->testTimeout(); + } catch (S3Exception $e) { + $logger->debug('Invalid remote storage.', [ + 'exception' => $e, + 'app' => 'objectstore', + ]); + if ($e->getAwsErrorCode() !== 'BucketAlreadyOwnedByYou') { + throw new StorageNotAvailableException('Creation of bucket "' . $this->bucket . '" failed. ' . $e->getMessage()); + } } - $this->connection->createBucket(['Bucket' => $this->bucket]); - $this->testTimeout(); - } catch (S3Exception $e) { - $logger->debug('Invalid remote storage.', [ - 'exception' => $e, - 'app' => 'objectstore', - ]); - throw new \Exception('Creation of bucket "' . $this->bucket . '" failed. ' . $e->getMessage()); } - } - // google cloud's s3 compatibility doesn't like the EncodingType parameter - if (strpos($base_url, 'storage.googleapis.com')) { - $this->connection->getHandlerList()->remove('s3.auto_encode'); + // google cloud's s3 compatibility doesn't like the EncodingType parameter + if (strpos($base_url, 'storage.googleapis.com')) { + $this->connection->getHandlerList()->remove('s3.auto_encode'); + } + } catch (S3Exception $e) { + throw new StorageNotAvailableException('S3 service is unable to handle request: ' . $e->getMessage()); } return $this->connection; @@ -205,10 +190,12 @@ trait S3ConnectionTrait { return function () { $key = empty($this->params['key']) ? null : $this->params['key']; $secret = empty($this->params['secret']) ? null : $this->params['secret']; + $sessionToken = empty($this->params['session_token']) ? null : $this->params['session_token']; if ($key && $secret) { - return Promise\promise_for( - new Credentials($key, $secret) + return Create::promiseFor( + // a null sessionToken match the default signature of the constructor + new Credentials($key, $secret, $sessionToken) ); } @@ -218,11 +205,11 @@ trait S3ConnectionTrait { } protected function getCertificateBundlePath(): ?string { - if ((int)($this->params['use_nextcloud_bundle'] ?? "0")) { + if ((int)($this->params['use_nextcloud_bundle'] ?? '0')) { // since we store the certificate bundles on the primary storage, we can't get the bundle while setting up the primary storage if (!isset($this->params['primary_storage'])) { /** @var ICertificateManager $certManager */ - $certManager = \OC::$server->get(ICertificateManager::class); + $certManager = Server::get(ICertificateManager::class); return $certManager->getAbsoluteBundlePath(); } else { return \OC::$SERVERROOT . '/resources/config/ca-bundle.crt'; @@ -233,7 +220,7 @@ trait S3ConnectionTrait { } protected function getSSECKey(): ?string { - if (isset($this->params['sse_c_key'])) { + if (isset($this->params['sse_c_key']) && !empty($this->params['sse_c_key'])) { return $this->params['sse_c_key']; } diff --git a/lib/private/Files/ObjectStore/S3ObjectTrait.php b/lib/private/Files/ObjectStore/S3ObjectTrait.php index 8fa6d67faa3..89405de2e8e 100644 --- a/lib/private/Files/ObjectStore/S3ObjectTrait.php +++ b/lib/private/Files/ObjectStore/S3ObjectTrait.php @@ -1,32 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Florent <florent@coppint.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\ObjectStore; +use Aws\Command; +use Aws\Exception\MultipartUploadException; use Aws\S3\Exception\S3MultipartUploadException; +use Aws\S3\MultipartCopy; use Aws\S3\MultipartUploader; use Aws\S3\S3Client; use GuzzleHttp\Psr7; @@ -35,6 +18,8 @@ use OC\Files\Stream\SeekableHttpStream; use Psr\Http\Message\StreamInterface; trait S3ObjectTrait { + use S3ConfigTrait; + /** * Returns the connection * @@ -54,7 +39,7 @@ trait S3ObjectTrait { * @since 7.0.0 */ public function readObject($urn) { - return SeekableHttpStream::open(function ($range) use ($urn) { + $fh = SeekableHttpStream::open(function ($range) use ($urn) { $command = $this->getConnection()->getCommand('GetObject', [ 'Bucket' => $this->bucket, 'Key' => $urn, @@ -88,26 +73,48 @@ trait S3ObjectTrait { $context = stream_context_create($opts); return fopen($request->getUri(), 'r', false, $context); }); + if (!$fh) { + throw new \Exception("Failed to read object $urn"); + } + return $fh; } + private function buildS3Metadata(array $metadata): array { + $result = []; + foreach ($metadata as $key => $value) { + $result['x-amz-meta-' . $key] = $value; + } + return $result; + } /** * Single object put helper * * @param string $urn the unified resource name used to identify the object * @param StreamInterface $stream stream with the data to write - * @param string|null $mimetype the mimetype to set for the remove object @since 22.0.0 + * @param array $metaData the metadata to set for the object * @throws \Exception when something goes wrong, message will be logged */ - protected function writeSingle(string $urn, StreamInterface $stream, string $mimetype = null): void { - $this->getConnection()->putObject([ + protected function writeSingle(string $urn, StreamInterface $stream, array $metaData): void { + $mimetype = $metaData['mimetype'] ?? null; + unset($metaData['mimetype']); + unset($metaData['size']); + + $args = [ 'Bucket' => $this->bucket, 'Key' => $urn, 'Body' => $stream, 'ACL' => 'private', 'ContentType' => $mimetype, + 'Metadata' => $this->buildS3Metadata($metaData), 'StorageClass' => $this->storageClass, - ] + $this->getSSECParameters()); + ] + $this->getSSECParameters(); + + if ($size = $stream->getSize()) { + $args['ContentLength'] = $size; + } + + $this->getConnection()->putObject($args); } @@ -116,56 +123,116 @@ trait S3ObjectTrait { * * @param string $urn the unified resource name used to identify the object * @param StreamInterface $stream stream with the data to write - * @param string|null $mimetype the mimetype to set for the remove object + * @param array $metaData the metadata to set for the object * @throws \Exception when something goes wrong, message will be logged */ - protected function writeMultiPart(string $urn, StreamInterface $stream, string $mimetype = null): void { - $uploader = new MultipartUploader($this->getConnection(), $stream, [ - 'bucket' => $this->bucket, - 'key' => $urn, - 'part_size' => $this->uploadPartSize, - 'params' => [ - 'ContentType' => $mimetype, - 'StorageClass' => $this->storageClass, - ] + $this->getSSECParameters(), - ]); + protected function writeMultiPart(string $urn, StreamInterface $stream, array $metaData): void { + $mimetype = $metaData['mimetype'] ?? null; + unset($metaData['mimetype']); + unset($metaData['size']); + + $attempts = 0; + $uploaded = false; + $concurrency = $this->concurrency; + $exception = null; + $state = null; + $size = $stream->getSize(); + $totalWritten = 0; + + // retry multipart upload once with concurrency at half on failure + while (!$uploaded && $attempts <= 1) { + $uploader = new MultipartUploader($this->getConnection(), $stream, [ + 'bucket' => $this->bucket, + 'concurrency' => $concurrency, + 'key' => $urn, + 'part_size' => $this->uploadPartSize, + 'state' => $state, + 'params' => [ + 'ContentType' => $mimetype, + 'Metadata' => $this->buildS3Metadata($metaData), + 'StorageClass' => $this->storageClass, + ] + $this->getSSECParameters(), + 'before_upload' => function (Command $command) use (&$totalWritten) { + $totalWritten += $command['ContentLength']; + }, + 'before_complete' => function ($_command) use (&$totalWritten, $size, &$uploader, &$attempts) { + if ($size !== null && $totalWritten != $size) { + $e = new \Exception('Incomplete multi part upload, expected ' . $size . ' bytes, wrote ' . $totalWritten); + throw new MultipartUploadException($uploader->getState(), $e); + } + }, + ]); + + try { + $uploader->upload(); + $uploaded = true; + } catch (S3MultipartUploadException $e) { + $exception = $e; + $attempts++; + + if ($concurrency > 1) { + $concurrency = round($concurrency / 2); + } + + if ($stream->isSeekable()) { + $stream->rewind(); + } + } catch (MultipartUploadException $e) { + $exception = $e; + break; + } + } - try { - $uploader->upload(); - } catch (S3MultipartUploadException $e) { + if (!$uploaded) { // if anything goes wrong with multipart, make sure that you don´t poison and // slow down s3 bucket with orphaned fragments - $uploadInfo = $e->getState()->getId(); - if ($e->getState()->isInitiated() && (array_key_exists('UploadId', $uploadInfo))) { + $uploadInfo = $exception->getState()->getId(); + if ($exception->getState()->isInitiated() && (array_key_exists('UploadId', $uploadInfo))) { $this->getConnection()->abortMultipartUpload($uploadInfo); } - throw new \OCA\DAV\Connector\Sabre\Exception\BadGateway("Error while uploading to S3 bucket", 0, $e); + + throw new \OCA\DAV\Connector\Sabre\Exception\BadGateway('Error while uploading to S3 bucket', 0, $exception); } } + public function writeObject($urn, $stream, ?string $mimetype = null) { + $metaData = []; + if ($mimetype) { + $metaData['mimetype'] = $mimetype; + } + $this->writeObjectWithMetaData($urn, $stream, $metaData); + } - /** - * @param string $urn the unified resource name used to identify the object - * @param resource $stream stream with the data to write - * @param string|null $mimetype the mimetype to set for the remove object @since 22.0.0 - * @throws \Exception when something goes wrong, message will be logged - * @since 7.0.0 - */ - public function writeObject($urn, $stream, string $mimetype = null) { - $psrStream = Utils::streamFor($stream); - - // ($psrStream->isSeekable() && $psrStream->getSize() !== null) evaluates to true for a On-Seekable stream - // so the optimisation does not apply - $buffer = new Psr7\Stream(fopen("php://memory", 'rwb+')); - Utils::copyToStream($psrStream, $buffer, $this->putSizeLimit); - $buffer->seek(0); - if ($buffer->getSize() < $this->putSizeLimit) { - // buffer is fully seekable, so use it directly for the small upload - $this->writeSingle($urn, $buffer, $mimetype); + public function writeObjectWithMetaData(string $urn, $stream, array $metaData): void { + $canSeek = fseek($stream, 0, SEEK_CUR) === 0; + $psrStream = Utils::streamFor($stream, [ + 'size' => $metaData['size'] ?? null, + ]); + + + $size = $psrStream->getSize(); + if ($size === null || !$canSeek) { + // The s3 single-part upload requires the size to be known for the stream. + // So for input streams that don't have a known size, we need to copy (part of) + // the input into a temporary stream so the size can be determined + $buffer = new Psr7\Stream(fopen('php://temp', 'rw+')); + Utils::copyToStream($psrStream, $buffer, $this->putSizeLimit); + $buffer->seek(0); + if ($buffer->getSize() < $this->putSizeLimit) { + // buffer is fully seekable, so use it directly for the small upload + $this->writeSingle($urn, $buffer, $metaData); + } else { + $loadStream = new Psr7\AppendStream([$buffer, $psrStream]); + $this->writeMultiPart($urn, $loadStream, $metaData); + } } else { - $loadStream = new Psr7\AppendStream([$buffer, $psrStream]); - $this->writeMultiPart($urn, $loadStream, $mimetype); + if ($size < $this->putSizeLimit) { + $this->writeSingle($urn, $psrStream, $metaData); + } else { + $this->writeMultiPart($urn, $psrStream, $metaData); + } } + $psrStream->close(); } /** @@ -185,9 +252,31 @@ trait S3ObjectTrait { return $this->getConnection()->doesObjectExist($this->bucket, $urn, $this->getSSECParameters()); } - public function copyObject($from, $to) { - $this->getConnection()->copy($this->getBucket(), $from, $this->getBucket(), $to, 'private', [ - 'params' => $this->getSSECParameters() + $this->getSSECParameters(true) - ]); + public function copyObject($from, $to, array $options = []) { + $sourceMetadata = $this->getConnection()->headObject([ + 'Bucket' => $this->getBucket(), + 'Key' => $from, + ] + $this->getSSECParameters()); + + $size = (int)($sourceMetadata->get('Size') ?? $sourceMetadata->get('ContentLength')); + + if ($this->useMultipartCopy && $size > $this->copySizeLimit) { + $copy = new MultipartCopy($this->getConnection(), [ + 'source_bucket' => $this->getBucket(), + 'source_key' => $from + ], array_merge([ + 'bucket' => $this->getBucket(), + 'key' => $to, + 'acl' => 'private', + 'params' => $this->getSSECParameters() + $this->getSSECParameters(true), + 'source_metadata' => $sourceMetadata + ], $options)); + $copy->copy(); + } else { + $this->getConnection()->copy($this->getBucket(), $from, $this->getBucket(), $to, 'private', array_merge([ + 'params' => $this->getSSECParameters() + $this->getSSECParameters(true), + 'mup_threshold' => PHP_INT_MAX, + ], $options)); + } } } diff --git a/lib/private/Files/ObjectStore/S3Signature.php b/lib/private/Files/ObjectStore/S3Signature.php index 64b994bac22..b80382ff67d 100644 --- a/lib/private/Files/ObjectStore/S3Signature.php +++ b/lib/private/Files/ObjectStore/S3Signature.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Robin Appelman <robin@icewind.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\ObjectStore; @@ -60,7 +42,7 @@ class S3Signature implements SignatureInterface { public function signRequest( RequestInterface $request, - CredentialsInterface $credentials + CredentialsInterface $credentials, ) { $request = $this->prepareRequest($request, $credentials); $stringToSign = $this->createCanonicalizedString($request); @@ -75,7 +57,7 @@ class S3Signature implements SignatureInterface { RequestInterface $request, CredentialsInterface $credentials, $expires, - array $options = [] + array $options = [], ) { $query = []; // URL encoding already occurs in the URI template expansion. Undo that @@ -107,25 +89,25 @@ class S3Signature implements SignatureInterface { // Move X-Amz-* headers to the query string foreach ($request->getHeaders() as $name => $header) { $name = strtolower($name); - if (strpos($name, 'x-amz-') === 0) { + if (str_starts_with($name, 'x-amz-')) { $query[$name] = implode(',', $header); } } - $queryString = http_build_query($query, null, '&', PHP_QUERY_RFC3986); + $queryString = http_build_query($query, '', '&', PHP_QUERY_RFC3986); return $request->withUri($request->getUri()->withQuery($queryString)); } /** - * @param RequestInterface $request + * @param RequestInterface $request * @param CredentialsInterface $creds * * @return RequestInterface */ private function prepareRequest( RequestInterface $request, - CredentialsInterface $creds + CredentialsInterface $creds, ) { $modify = [ 'remove_headers' => ['X-Amz-Date'], @@ -148,7 +130,7 @@ class S3Signature implements SignatureInterface { private function createCanonicalizedString( RequestInterface $request, - $expires = null + $expires = null, ) { $buffer = $request->getMethod() . "\n"; @@ -169,7 +151,7 @@ class S3Signature implements SignatureInterface { $headers = []; foreach ($request->getHeaders() as $name => $header) { $name = strtolower($name); - if (strpos($name, 'x-amz-') === 0) { + if (str_starts_with($name, 'x-amz-')) { $value = implode(',', $header); if (strlen($value) > 0) { $headers[$name] = $name . ':' . $value; diff --git a/lib/private/Files/ObjectStore/StorageObjectStore.php b/lib/private/Files/ObjectStore/StorageObjectStore.php index 85926be897e..888602a62e4 100644 --- a/lib/private/Files/ObjectStore/StorageObjectStore.php +++ b/lib/private/Files/ObjectStore/StorageObjectStore.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Robin Appelman <robin@icewind.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\ObjectStore; @@ -45,8 +28,8 @@ class StorageObjectStore implements IObjectStore { * @return string the container or bucket name where objects are stored * @since 7.0.0 */ - public function getStorageId() { - $this->storage->getId(); + public function getStorageId(): string { + return $this->storage->getId(); } /** @@ -64,7 +47,7 @@ class StorageObjectStore implements IObjectStore { throw new \Exception(); } - public function writeObject($urn, $stream, string $mimetype = null) { + public function writeObject($urn, $stream, ?string $mimetype = null) { $handle = $this->storage->fopen($urn, 'w'); if ($handle) { stream_copy_to_stream($stream, $handle); diff --git a/lib/private/Files/ObjectStore/Swift.php b/lib/private/Files/ObjectStore/Swift.php index b463cb9d44d..aa8b3bb34ec 100644 --- a/lib/private/Files/ObjectStore/Swift.php +++ b/lib/private/Files/ObjectStore/Swift.php @@ -1,27 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Adrian Brzezinski <adrian.brzezinski@eo.pl> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\ObjectStore; @@ -45,7 +27,7 @@ class Swift implements IObjectStore { /** @var SwiftFactory */ private $swiftFactory; - public function __construct($params, SwiftFactory $connectionFactory = null) { + public function __construct($params, ?SwiftFactory $connectionFactory = null) { $this->swiftFactory = $connectionFactory ?: new SwiftFactory( \OC::$server->getMemCacheFactory()->createDistributed('swift::'), $params, @@ -74,7 +56,7 @@ class Swift implements IObjectStore { return $this->params['container']; } - public function writeObject($urn, $stream, string $mimetype = null) { + public function writeObject($urn, $stream, ?string $mimetype = null) { $tmpFile = \OC::$server->getTempManager()->getTemporaryFile('swiftwrite'); file_put_contents($tmpFile, $stream); $handle = fopen($tmpFile, 'rb'); diff --git a/lib/private/Files/ObjectStore/SwiftFactory.php b/lib/private/Files/ObjectStore/SwiftFactory.php index bd75ccada2e..118724159e5 100644 --- a/lib/private/Files/ObjectStore/SwiftFactory.php +++ b/lib/private/Files/ObjectStore/SwiftFactory.php @@ -3,32 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Robin Appelman <robin@icewind.nl> - * - * @author Adrian Brzezinski <adrian.brzezinski@eo.pl> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Julien Lutran <julien.lutran@corp.ovh.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Volker <skydiablo@gmx.net> - * @author William Pain <pain.william@gmail.com> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\ObjectStore; @@ -194,7 +170,7 @@ class SwiftFactory { try { /** @var \OpenStack\Identity\v2\Models\Token $token */ $token = $authService->model(\OpenStack\Identity\v2\Models\Token::class, $cachedToken['token']); - $now = new \DateTimeImmutable("now"); + $now = new \DateTimeImmutable('now'); if ($token->expires > $now) { $hasValidCachedToken = true; $this->params['v2cachedToken'] = $token; @@ -218,13 +194,13 @@ class SwiftFactory { } catch (ClientException $e) { $statusCode = $e->getResponse()->getStatusCode(); if ($statusCode === 404) { - throw new StorageAuthException('Keystone not found, verify the keystone url', $e); + throw new StorageAuthException('Keystone not found while connecting to object storage, verify the keystone url', $e); } elseif ($statusCode === 412) { - throw new StorageAuthException('Precondition failed, verify the keystone url', $e); + throw new StorageAuthException('Precondition failed while connecting to object storage, verify the keystone url', $e); } elseif ($statusCode === 401) { - throw new StorageAuthException('Authentication failed, verify the username, password and possibly tenant', $e); + throw new StorageAuthException('Authentication failed while connecting to object storage, verify the username, password and possibly tenant', $e); } else { - throw new StorageAuthException('Unknown error', $e); + throw new StorageAuthException('Unknown error while connecting to object storage', $e); } } catch (RequestException $e) { throw new StorageAuthException('Connection reset while connecting to keystone, verify the keystone url', $e); diff --git a/lib/private/Files/ObjectStore/SwiftV2CachingAuthService.php b/lib/private/Files/ObjectStore/SwiftV2CachingAuthService.php index b1478762550..266781af142 100644 --- a/lib/private/Files/ObjectStore/SwiftV2CachingAuthService.php +++ b/lib/private/Files/ObjectStore/SwiftV2CachingAuthService.php @@ -3,33 +3,19 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\ObjectStore; +use OpenStack\Common\Auth\Token; use OpenStack\Identity\v2\Service; class SwiftV2CachingAuthService extends Service { public function authenticate(array $options = []): array { - if (!empty($options['v2cachedToken'])) { + if (isset($options['v2cachedToken'], $options['v2serviceUrl']) + && $options['v2cachedToken'] instanceof Token + && is_string($options['v2serviceUrl'])) { return [$options['v2cachedToken'], $options['v2serviceUrl']]; } else { return parent::authenticate($options); diff --git a/lib/private/Files/Search/QueryOptimizer/FlattenNestedBool.php b/lib/private/Files/Search/QueryOptimizer/FlattenNestedBool.php new file mode 100644 index 00000000000..bb7bef2ed63 --- /dev/null +++ b/lib/private/Files/Search/QueryOptimizer/FlattenNestedBool.php @@ -0,0 +1,33 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files\Search\QueryOptimizer; + +use OC\Files\Search\SearchBinaryOperator; +use OCP\Files\Search\ISearchBinaryOperator; +use OCP\Files\Search\ISearchOperator; + +class FlattenNestedBool extends QueryOptimizerStep { + public function processOperator(ISearchOperator &$operator) { + if ( + $operator instanceof SearchBinaryOperator && ( + $operator->getType() === ISearchBinaryOperator::OPERATOR_OR + || $operator->getType() === ISearchBinaryOperator::OPERATOR_AND + ) + ) { + $newArguments = []; + foreach ($operator->getArguments() as $oldArgument) { + if ($oldArgument instanceof SearchBinaryOperator && $oldArgument->getType() === $operator->getType()) { + $newArguments = array_merge($newArguments, $oldArgument->getArguments()); + } else { + $newArguments[] = $oldArgument; + } + } + $operator->setArguments($newArguments); + } + parent::processOperator($operator); + } +} diff --git a/lib/private/Files/Search/QueryOptimizer/FlattenSingleArgumentBinaryOperation.php b/lib/private/Files/Search/QueryOptimizer/FlattenSingleArgumentBinaryOperation.php new file mode 100644 index 00000000000..7e99c04f197 --- /dev/null +++ b/lib/private/Files/Search/QueryOptimizer/FlattenSingleArgumentBinaryOperation.php @@ -0,0 +1,31 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files\Search\QueryOptimizer; + +use OCP\Files\Search\ISearchBinaryOperator; +use OCP\Files\Search\ISearchOperator; + +/** + * replace single argument AND and OR operations with their single argument + */ +class FlattenSingleArgumentBinaryOperation extends ReplacingOptimizerStep { + public function processOperator(ISearchOperator &$operator): bool { + parent::processOperator($operator); + if ( + $operator instanceof ISearchBinaryOperator + && count($operator->getArguments()) === 1 + && ( + $operator->getType() === ISearchBinaryOperator::OPERATOR_OR + || $operator->getType() === ISearchBinaryOperator::OPERATOR_AND + ) + ) { + $operator = $operator->getArguments()[0]; + return true; + } + return false; + } +} diff --git a/lib/private/Files/Search/QueryOptimizer/MergeDistributiveOperations.php b/lib/private/Files/Search/QueryOptimizer/MergeDistributiveOperations.php new file mode 100644 index 00000000000..4949ca7396b --- /dev/null +++ b/lib/private/Files/Search/QueryOptimizer/MergeDistributiveOperations.php @@ -0,0 +1,99 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files\Search\QueryOptimizer; + +use OC\Files\Search\SearchBinaryOperator; +use OC\Files\Search\SearchComparison; +use OCP\Files\Search\ISearchBinaryOperator; +use OCP\Files\Search\ISearchOperator; + +/** + * Attempt to transform + * + * (A AND B) OR (A AND C) OR (A AND D AND E) into A AND (B OR C OR (D AND E)) + * + * This is always valid because logical 'AND' and 'OR' are distributive[1]. + * + * [1]: https://en.wikipedia.org/wiki/Distributive_property + */ +class MergeDistributiveOperations extends ReplacingOptimizerStep { + public function processOperator(ISearchOperator &$operator): bool { + if ($operator instanceof SearchBinaryOperator) { + // either 'AND' or 'OR' + $topLevelType = $operator->getType(); + + // split the arguments into groups that share a first argument + $groups = $this->groupBinaryOperatorsByChild($operator->getArguments(), 0); + $outerOperations = array_map(function (array $operators) use ($topLevelType) { + // no common operations, no need to change anything + if (count($operators) === 1) { + return $operators[0]; + } + + // for groups with size >1 we know they are binary operators with at least 1 child + /** @var ISearchBinaryOperator $firstArgument */ + $firstArgument = $operators[0]; + + // we already checked that all arguments have the same type, so this type applies for all, either 'AND' or 'OR' + $innerType = $firstArgument->getType(); + + // the common operation we move out ('A' from the example) + $extractedLeftHand = $firstArgument->getArguments()[0]; + + // for each argument we remove the extracted operation to get the leftovers ('B', 'C' and '(D AND E)' in the example) + // note that we leave them inside the "inner" binary operation for when the "inner" operation contained more than two parts + // in the (common) case where the "inner" operation only has 1 item left it will be cleaned up in a follow up step + $rightHandArguments = array_map(function (ISearchOperator $inner) { + /** @var ISearchBinaryOperator $inner */ + $arguments = $inner->getArguments(); + array_shift($arguments); + if (count($arguments) === 1) { + return $arguments[0]; + } + return new SearchBinaryOperator($inner->getType(), $arguments); + }, $operators); + + // combine the extracted operation ('A') with the remaining bit ('(B OR C OR (D AND E))') + // note that because of how distribution work, we use the "outer" type "inside" and the "inside" type "outside". + $extractedRightHand = new SearchBinaryOperator($topLevelType, $rightHandArguments); + return new SearchBinaryOperator( + $innerType, + [$extractedLeftHand, $extractedRightHand] + ); + }, $groups); + + // combine all groups again + $operator = new SearchBinaryOperator($topLevelType, $outerOperations); + parent::processOperator($operator); + return true; + } + return parent::processOperator($operator); + } + + /** + * Group a list of binary search operators that have a common argument + * + * Non-binary operators, or empty binary operators will each get their own 1-sized group + * + * @param ISearchOperator[] $operators + * @return ISearchOperator[][] + */ + private function groupBinaryOperatorsByChild(array $operators, int $index = 0): array { + $result = []; + foreach ($operators as $operator) { + if ($operator instanceof ISearchBinaryOperator && count($operator->getArguments()) > 0) { + /** @var SearchBinaryOperator|SearchComparison $child */ + $child = $operator->getArguments()[$index]; + $childKey = (string)$child; + $result[$childKey][] = $operator; + } else { + $result[] = [$operator]; + } + } + return array_values($result); + } +} diff --git a/lib/private/Files/Search/QueryOptimizer/OrEqualsToIn.php b/lib/private/Files/Search/QueryOptimizer/OrEqualsToIn.php new file mode 100644 index 00000000000..6df35c9c9a2 --- /dev/null +++ b/lib/private/Files/Search/QueryOptimizer/OrEqualsToIn.php @@ -0,0 +1,74 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files\Search\QueryOptimizer; + +use OC\Files\Search\SearchBinaryOperator; +use OC\Files\Search\SearchComparison; +use OCP\Files\Search\ISearchBinaryOperator; +use OCP\Files\Search\ISearchComparison; +use OCP\Files\Search\ISearchOperator; + +/** + * transform (field == A OR field == B ...) into field IN (A, B, ...) + */ +class OrEqualsToIn extends ReplacingOptimizerStep { + public function processOperator(ISearchOperator &$operator): bool { + if ( + $operator instanceof ISearchBinaryOperator + && $operator->getType() === ISearchBinaryOperator::OPERATOR_OR + ) { + $groups = $this->groupEqualsComparisonsByField($operator->getArguments()); + $newParts = array_map(function (array $group) { + if (count($group) > 1) { + // because of the logic from `groupEqualsComparisonsByField` we now that group is all comparisons on the same field + /** @var ISearchComparison[] $group */ + $field = $group[0]->getField(); + $values = array_map(function (ISearchComparison $comparison) { + /** @var string|integer|bool|\DateTime $value */ + $value = $comparison->getValue(); + return $value; + }, $group); + $in = new SearchComparison(ISearchComparison::COMPARE_IN, $field, $values, $group[0]->getExtra()); + $pathEqHash = array_reduce($group, function ($pathEqHash, ISearchComparison $comparison) { + return $comparison->getQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, true) && $pathEqHash; + }, true); + $in->setQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, $pathEqHash); + return $in; + } else { + return $group[0]; + } + }, $groups); + if (count($newParts) === 1) { + $operator = $newParts[0]; + } else { + $operator = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, $newParts); + } + parent::processOperator($operator); + return true; + } + parent::processOperator($operator); + return false; + } + + /** + * Non-equals operators are put in a separate group for each + * + * @param ISearchOperator[] $operators + * @return ISearchOperator[][] + */ + private function groupEqualsComparisonsByField(array $operators): array { + $result = []; + foreach ($operators as $operator) { + if ($operator instanceof ISearchComparison && $operator->getType() === ISearchComparison::COMPARE_EQUAL) { + $result[$operator->getField()][] = $operator; + } else { + $result[] = [$operator]; + } + } + return array_values($result); + } +} diff --git a/lib/private/Files/Search/QueryOptimizer/PathPrefixOptimizer.php b/lib/private/Files/Search/QueryOptimizer/PathPrefixOptimizer.php index 62182303ffd..2994a9365a7 100644 --- a/lib/private/Files/Search/QueryOptimizer/PathPrefixOptimizer.php +++ b/lib/private/Files/Search/QueryOptimizer/PathPrefixOptimizer.php @@ -2,25 +2,9 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Files\Search\QueryOptimizer; use OC\Files\Search\SearchComparison; @@ -29,27 +13,49 @@ use OCP\Files\Search\ISearchComparison; use OCP\Files\Search\ISearchOperator; class PathPrefixOptimizer extends QueryOptimizerStep { - public function processOperator(ISearchOperator &$operator) { - // normally the `path = "$prefix"` search query part of the prefix filter would be generated as an `path_hash = md5($prefix)` sql query + private bool $useHashEq = true; + + public function inspectOperator(ISearchOperator $operator): void { + // normally any `path = "$path"` search filter would be generated as an `path_hash = md5($path)` sql query // since the `path_hash` sql column usually provides much faster querying that selecting on the `path` sql column // - // however, since we're already doing a filter on the `path` column in the form of `path LIKE "$prefix/%"` + // however, if we're already doing a filter on the `path` column in the form of `path LIKE "$prefix/%"` // generating a `path = "$prefix"` sql query lets the database handle use the same column for both expressions and potentially use the same index + // + // If there is any operator in the query that matches this pattern, we change all `path = "$path"` instances to not the `path_hash` equality, + // otherwise mariadb has a tendency of ignoring the path_prefix index + if ($this->useHashEq && $this->isPathPrefixOperator($operator)) { + $this->useHashEq = false; + } + + parent::inspectOperator($operator); + } + + public function processOperator(ISearchOperator &$operator) { + if (!$this->useHashEq && $operator instanceof ISearchComparison && !$operator->getExtra() && $operator->getField() === 'path' && $operator->getType() === ISearchComparison::COMPARE_EQUAL) { + $operator->setQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, false); + } + + parent::processOperator($operator); + } + + private function isPathPrefixOperator(ISearchOperator $operator): bool { if ($operator instanceof ISearchBinaryOperator && $operator->getType() === ISearchBinaryOperator::OPERATOR_OR && count($operator->getArguments()) == 2) { $a = $operator->getArguments()[0]; $b = $operator->getArguments()[1]; - if ($a instanceof ISearchComparison && $b instanceof ISearchComparison && $a->getField() === 'path' && $b->getField() === 'path') { - if ($a->getType() === ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE && $b->getType() === ISearchComparison::COMPARE_EQUAL - && $a->getValue() === SearchComparison::escapeLikeParameter($b->getValue()) . '/%') { - $b->setQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, false); - } - if ($b->getType() === ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE && $a->getType() === ISearchComparison::COMPARE_EQUAL - && $b->getValue() === SearchComparison::escapeLikeParameter($a->getValue()) . '/%') { - $a->setQueryHint(ISearchComparison::HINT_PATH_EQ_HASH, false); - } + if ($this->operatorPairIsPathPrefix($a, $b) || $this->operatorPairIsPathPrefix($b, $a)) { + return true; } } + return false; + } - parent::processOperator($operator); + private function operatorPairIsPathPrefix(ISearchOperator $like, ISearchOperator $equal): bool { + return ( + $like instanceof ISearchComparison && $equal instanceof ISearchComparison + && !$like->getExtra() && !$equal->getExtra() && $like->getField() === 'path' && $equal->getField() === 'path' + && $like->getType() === ISearchComparison::COMPARE_LIKE_CASE_SENSITIVE && $equal->getType() === ISearchComparison::COMPARE_EQUAL + && $like->getValue() === SearchComparison::escapeLikeParameter($equal->getValue()) . '/%' + ); } } diff --git a/lib/private/Files/Search/QueryOptimizer/QueryOptimizer.php b/lib/private/Files/Search/QueryOptimizer/QueryOptimizer.php index 1635e50335a..5259ca25ad3 100644 --- a/lib/private/Files/Search/QueryOptimizer/QueryOptimizer.php +++ b/lib/private/Files/Search/QueryOptimizer/QueryOptimizer.php @@ -2,23 +2,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Search\QueryOptimizer; @@ -29,15 +14,23 @@ class QueryOptimizer { /** @var QueryOptimizerStep[] */ private $steps = []; - public function __construct( - PathPrefixOptimizer $pathPrefixOptimizer - ) { + public function __construct() { + // note that the order here is relevant $this->steps = [ - $pathPrefixOptimizer + new PathPrefixOptimizer(), + new MergeDistributiveOperations(), + new FlattenSingleArgumentBinaryOperation(), + new FlattenNestedBool(), + new OrEqualsToIn(), + new FlattenNestedBool(), + new SplitLargeIn(), ]; } - public function processOperator(ISearchOperator $operator) { + public function processOperator(ISearchOperator &$operator) { + foreach ($this->steps as $step) { + $step->inspectOperator($operator); + } foreach ($this->steps as $step) { $step->processOperator($operator); } diff --git a/lib/private/Files/Search/QueryOptimizer/QueryOptimizerStep.php b/lib/private/Files/Search/QueryOptimizer/QueryOptimizerStep.php index 4f683899723..15b5b580ec1 100644 --- a/lib/private/Files/Search/QueryOptimizer/QueryOptimizerStep.php +++ b/lib/private/Files/Search/QueryOptimizer/QueryOptimizerStep.php @@ -2,23 +2,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Search\QueryOptimizer; @@ -27,6 +12,26 @@ use OCP\Files\Search\ISearchBinaryOperator; use OCP\Files\Search\ISearchOperator; class QueryOptimizerStep { + /** + * Allow optimizer steps to inspect the entire query before starting processing + * + * @param ISearchOperator $operator + * @return void + */ + public function inspectOperator(ISearchOperator $operator): void { + if ($operator instanceof ISearchBinaryOperator) { + foreach ($operator->getArguments() as $argument) { + $this->inspectOperator($argument); + } + } + } + + /** + * Allow optimizer steps to modify query operators + * + * @param ISearchOperator $operator + * @return void + */ public function processOperator(ISearchOperator &$operator) { if ($operator instanceof ISearchBinaryOperator) { foreach ($operator->getArguments() as $argument) { diff --git a/lib/private/Files/Search/QueryOptimizer/ReplacingOptimizerStep.php b/lib/private/Files/Search/QueryOptimizer/ReplacingOptimizerStep.php new file mode 100644 index 00000000000..a9c9ba876bc --- /dev/null +++ b/lib/private/Files/Search/QueryOptimizer/ReplacingOptimizerStep.php @@ -0,0 +1,37 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files\Search\QueryOptimizer; + +use OC\Files\Search\SearchBinaryOperator; +use OCP\Files\Search\ISearchOperator; + +/** + * Optimizer step that can replace the $operator altogether instead of just modifying it + * These steps need some extra logic to properly replace the arguments of binary operators + */ +class ReplacingOptimizerStep extends QueryOptimizerStep { + /** + * Allow optimizer steps to modify query operators + * + * Returns true if the reference $operator points to a new value + */ + public function processOperator(ISearchOperator &$operator): bool { + if ($operator instanceof SearchBinaryOperator) { + $modified = false; + $arguments = $operator->getArguments(); + foreach ($arguments as &$argument) { + if ($this->processOperator($argument)) { + $modified = true; + } + } + if ($modified) { + $operator->setArguments($arguments); + } + } + return false; + } +} diff --git a/lib/private/Files/Search/QueryOptimizer/SplitLargeIn.php b/lib/private/Files/Search/QueryOptimizer/SplitLargeIn.php new file mode 100644 index 00000000000..8aee1975708 --- /dev/null +++ b/lib/private/Files/Search/QueryOptimizer/SplitLargeIn.php @@ -0,0 +1,36 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files\Search\QueryOptimizer; + +use OC\Files\Search\SearchBinaryOperator; +use OC\Files\Search\SearchComparison; +use OCP\Files\Search\ISearchBinaryOperator; +use OCP\Files\Search\ISearchComparison; +use OCP\Files\Search\ISearchOperator; + +/** + * transform IN (1000+ element) into (IN (1000 elements) OR IN(...)) + */ +class SplitLargeIn extends ReplacingOptimizerStep { + public function processOperator(ISearchOperator &$operator): bool { + if ( + $operator instanceof ISearchComparison + && $operator->getType() === ISearchComparison::COMPARE_IN + && count($operator->getValue()) > 1000 + ) { + $chunks = array_chunk($operator->getValue(), 1000); + $chunkComparisons = array_map(function (array $values) use ($operator) { + return new SearchComparison(ISearchComparison::COMPARE_IN, $operator->getField(), $values, $operator->getExtra()); + }, $chunks); + + $operator = new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_OR, $chunkComparisons); + return true; + } + parent::processOperator($operator); + return false; + } +} diff --git a/lib/private/Files/Search/SearchBinaryOperator.php b/lib/private/Files/Search/SearchBinaryOperator.php index d7bba8f1b4e..49f599933f4 100644 --- a/lib/private/Files/Search/SearchBinaryOperator.php +++ b/lib/private/Files/Search/SearchBinaryOperator.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Search; @@ -28,7 +12,7 @@ use OCP\Files\Search\ISearchOperator; class SearchBinaryOperator implements ISearchBinaryOperator { /** @var string */ private $type; - /** @var ISearchOperator[] */ + /** @var (SearchBinaryOperator|SearchComparison)[] */ private $arguments; private $hints = []; @@ -36,7 +20,7 @@ class SearchBinaryOperator implements ISearchBinaryOperator { * SearchBinaryOperator constructor. * * @param string $type - * @param ISearchOperator[] $arguments + * @param (SearchBinaryOperator|SearchComparison)[] $arguments */ public function __construct($type, array $arguments) { $this->type = $type; @@ -57,6 +41,14 @@ class SearchBinaryOperator implements ISearchBinaryOperator { return $this->arguments; } + /** + * @param ISearchOperator[] $arguments + * @return void + */ + public function setArguments(array $arguments): void { + $this->arguments = $arguments; + } + public function getQueryHint(string $name, $default) { return $this->hints[$name] ?? $default; } @@ -64,4 +56,11 @@ class SearchBinaryOperator implements ISearchBinaryOperator { public function setQueryHint(string $name, $value): void { $this->hints[$name] = $value; } + + public function __toString(): string { + if ($this->type === ISearchBinaryOperator::OPERATOR_NOT) { + return '(not ' . $this->arguments[0] . ')'; + } + return '(' . implode(' ' . $this->type . ' ', $this->arguments) . ')'; + } } diff --git a/lib/private/Files/Search/SearchComparison.php b/lib/private/Files/Search/SearchComparison.php index 122a1f730b4..c1f0176afd9 100644 --- a/lib/private/Files/Search/SearchComparison.php +++ b/lib/private/Files/Search/SearchComparison.php @@ -1,70 +1,53 @@ <?php + +declare(strict_types=1); /** - * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Search; use OCP\Files\Search\ISearchComparison; +/** + * @psalm-import-type ParamValue from ISearchComparison + */ class SearchComparison implements ISearchComparison { - /** @var string */ - private $type; - /** @var string */ - private $field; - /** @var string|integer|\DateTime */ - private $value; - private $hints = []; + private array $hints = []; - /** - * SearchComparison constructor. - * - * @param string $type - * @param string $field - * @param \DateTime|int|string $value - */ - public function __construct($type, $field, $value) { - $this->type = $type; - $this->field = $field; - $this->value = $value; + public function __construct( + private string $type, + private string $field, + /** @var ParamValue $value */ + private \DateTime|int|string|bool|array $value, + private string $extra = '', + ) { } /** * @return string */ - public function getType() { + public function getType(): string { return $this->type; } /** * @return string */ - public function getField() { + public function getField(): string { return $this->field; } + public function getValue(): string|int|bool|\DateTime|array { + return $this->value; + } + /** - * @return \DateTime|int|string + * @return string + * @since 28.0.0 */ - public function getValue() { - return $this->value; + public function getExtra(): string { + return $this->extra; } public function getQueryHint(string $name, $default) { @@ -78,4 +61,8 @@ class SearchComparison implements ISearchComparison { public static function escapeLikeParameter(string $param): string { return addcslashes($param, '\\_%'); } + + public function __toString(): string { + return $this->field . ' ' . $this->type . ' ' . json_encode($this->value); + } } diff --git a/lib/private/Files/Search/SearchOrder.php b/lib/private/Files/Search/SearchOrder.php index 1395a87ac72..5a036653f4e 100644 --- a/lib/private/Files/Search/SearchOrder.php +++ b/lib/private/Files/Search/SearchOrder.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Search; @@ -26,34 +10,33 @@ use OCP\Files\FileInfo; use OCP\Files\Search\ISearchOrder; class SearchOrder implements ISearchOrder { - /** @var string */ - private $direction; - /** @var string */ - private $field; + public function __construct( + private string $direction, + private string $field, + private string $extra = '', + ) { + } /** - * SearchOrder constructor. - * - * @param string $direction - * @param string $field + * @return string */ - public function __construct($direction, $field) { - $this->direction = $direction; - $this->field = $field; + public function getDirection(): string { + return $this->direction; } /** * @return string */ - public function getDirection() { - return $this->direction; + public function getField(): string { + return $this->field; } /** * @return string + * @since 28.0.0 */ - public function getField() { - return $this->field; + public function getExtra(): string { + return $this->extra; } public function sortFileInfo(FileInfo $a, FileInfo $b): int { diff --git a/lib/private/Files/Search/SearchQuery.php b/lib/private/Files/Search/SearchQuery.php index 7c76bdff978..592749cf4a0 100644 --- a/lib/private/Files/Search/SearchQuery.php +++ b/lib/private/Files/Search/SearchQuery.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Search; @@ -28,13 +12,13 @@ use OCP\Files\Search\ISearchQuery; use OCP\IUser; class SearchQuery implements ISearchQuery { - /** @var ISearchOperator */ + /** @var ISearchOperator */ private $searchOperation; - /** @var integer */ + /** @var integer */ private $limit; - /** @var integer */ + /** @var integer */ private $offset; - /** @var ISearchOrder[] */ + /** @var ISearchOrder[] */ private $order; /** @var ?IUser */ private $user; @@ -56,7 +40,7 @@ class SearchQuery implements ISearchQuery { int $offset, array $order, ?IUser $user = null, - bool $limitToHome = false + bool $limitToHome = false, ) { $this->searchOperation = $searchOperation; $this->limit = $limit; diff --git a/lib/private/Files/SetupManager.php b/lib/private/Files/SetupManager.php index 979e4ce966a..b92c608a81d 100644 --- a/lib/private/Files/SetupManager.php +++ b/lib/private/Files/SetupManager.php @@ -2,30 +2,15 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files; use OC\Files\Config\MountProviderCollection; +use OC\Files\Mount\HomeMountPoint; use OC\Files\Mount\MountPoint; -use OC\Files\ObjectStore\HomeObjectStoreStorage; use OC\Files\Storage\Common; use OC\Files\Storage\Home; use OC\Files\Storage\Storage; @@ -34,16 +19,23 @@ use OC\Files\Storage\Wrapper\Encoding; use OC\Files\Storage\Wrapper\PermissionsMask; use OC\Files\Storage\Wrapper\Quota; use OC\Lockdown\Filesystem\NullStorage; -use OC_App; +use OC\Share\Share; +use OC\Share20\ShareDisableChecker; use OC_Hook; -use OC_Util; +use OCA\Files_External\Config\ExternalMountPoint; +use OCA\Files_Sharing\External\Mount; +use OCA\Files_Sharing\ISharedMountPoint; +use OCA\Files_Sharing\SharedMount; +use OCP\App\IAppManager; use OCP\Constants; use OCP\Diagnostics\IEventLogger; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Config\ICachedMountInfo; use OCP\Files\Config\IHomeMountProvider; use OCP\Files\Config\IMountProvider; +use OCP\Files\Config\IRootMountProvider; use OCP\Files\Config\IUserMountCache; +use OCP\Files\Events\BeforeFileSystemSetupEvent; use OCP\Files\Events\InvalidateMountCacheEvent; use OCP\Files\Events\Node\FilesystemTornDownEvent; use OCP\Files\Mount\IMountManager; @@ -64,52 +56,36 @@ use Psr\Log\LoggerInterface; class SetupManager { private bool $rootSetup = false; - private IEventLogger $eventLogger; - private MountProviderCollection $mountProviderCollection; - private IMountManager $mountManager; - private IUserManager $userManager; // List of users for which at least one mount is setup private array $setupUsers = []; // List of users for which all mounts are setup private array $setupUsersComplete = []; /** @var array<string, string[]> */ private array $setupUserMountProviders = []; - private IEventDispatcher $eventDispatcher; - private IUserMountCache $userMountCache; - private ILockdownManager $lockdownManager; - private IUserSession $userSession; private ICache $cache; - private LoggerInterface $logger; - private IConfig $config; private bool $listeningForProviders; private array $fullSetupRequired = []; private bool $setupBuiltinWrappersDone = false; + private bool $forceFullSetup = false; public function __construct( - IEventLogger $eventLogger, - MountProviderCollection $mountProviderCollection, - IMountManager $mountManager, - IUserManager $userManager, - IEventDispatcher $eventDispatcher, - IUserMountCache $userMountCache, - ILockdownManager $lockdownManager, - IUserSession $userSession, + private IEventLogger $eventLogger, + private MountProviderCollection $mountProviderCollection, + private IMountManager $mountManager, + private IUserManager $userManager, + private IEventDispatcher $eventDispatcher, + private IUserMountCache $userMountCache, + private ILockdownManager $lockdownManager, + private IUserSession $userSession, ICacheFactory $cacheFactory, - LoggerInterface $logger, - IConfig $config + private LoggerInterface $logger, + private IConfig $config, + private ShareDisableChecker $shareDisableChecker, + private IAppManager $appManager, ) { - $this->eventLogger = $eventLogger; - $this->mountProviderCollection = $mountProviderCollection; - $this->mountManager = $mountManager; - $this->userManager = $userManager; - $this->eventDispatcher = $eventDispatcher; - $this->userMountCache = $userMountCache; - $this->lockdownManager = $lockdownManager; - $this->logger = $logger; - $this->userSession = $userSession; $this->cache = $cacheFactory->createDistributed('setupmanager::'); $this->listeningForProviders = false; - $this->config = $config; + $this->forceFullSetup = $this->config->getSystemValueBool('debug.force-full-fs-setup'); $this->setupListeners(); } @@ -129,55 +105,60 @@ class SetupManager { $this->setupBuiltinWrappersDone = true; // load all filesystem apps before, so no setup-hook gets lost - OC_App::loadApps(['filesystem']); + $this->appManager->loadApps(['filesystem']); $prevLogging = Filesystem::logWarningWhenAddingStorageWrapper(false); Filesystem::addStorageWrapper('mount_options', function ($mountPoint, IStorage $storage, IMountPoint $mount) { if ($storage->instanceOfStorage(Common::class)) { - $storage->setMountOptions($mount->getOptions()); + $options = array_merge($mount->getOptions(), ['mount_point' => $mountPoint]); + $storage->setMountOptions($options); } return $storage; }); - Filesystem::addStorageWrapper('enable_sharing', function ($mountPoint, IStorage $storage, IMountPoint $mount) { - if (!$mount->getOption('enable_sharing', true)) { - return new PermissionsMask([ - 'storage' => $storage, - 'mask' => Constants::PERMISSION_ALL - Constants::PERMISSION_SHARE, - ]); + $reSharingEnabled = Share::isResharingAllowed(); + $user = $this->userSession->getUser(); + $sharingEnabledForUser = $user ? !$this->shareDisableChecker->sharingDisabledForUser($user->getUID()) : true; + Filesystem::addStorageWrapper( + 'sharing_mask', + function ($mountPoint, IStorage $storage, IMountPoint $mount) use ($reSharingEnabled, $sharingEnabledForUser) { + $sharingEnabledForMount = $mount->getOption('enable_sharing', true); + $isShared = $mount instanceof ISharedMountPoint; + if (!$sharingEnabledForMount || !$sharingEnabledForUser || (!$reSharingEnabled && $isShared)) { + return new PermissionsMask([ + 'storage' => $storage, + 'mask' => Constants::PERMISSION_ALL - Constants::PERMISSION_SHARE, + ]); + } + return $storage; } - return $storage; - }); + ); // install storage availability wrapper, before most other wrappers - Filesystem::addStorageWrapper('oc_availability', function ($mountPoint, IStorage $storage) { - if (!$storage->instanceOfStorage('\OCA\Files_Sharing\SharedStorage') && !$storage->isLocal()) { + Filesystem::addStorageWrapper('oc_availability', function ($mountPoint, IStorage $storage, IMountPoint $mount) { + $externalMount = $mount instanceof ExternalMountPoint || $mount instanceof Mount; + if ($externalMount && !$storage->isLocal()) { return new Availability(['storage' => $storage]); } return $storage; }); Filesystem::addStorageWrapper('oc_encoding', function ($mountPoint, IStorage $storage, IMountPoint $mount) { - if ($mount->getOption('encoding_compatibility', false) && !$storage->instanceOfStorage('\OCA\Files_Sharing\SharedStorage')) { + if ($mount->getOption('encoding_compatibility', false) && !$mount instanceof SharedMount) { return new Encoding(['storage' => $storage]); } return $storage; }); - Filesystem::addStorageWrapper('oc_quota', function ($mountPoint, $storage) { + $quotaIncludeExternal = $this->config->getSystemValue('quota_include_external_storage', false); + Filesystem::addStorageWrapper('oc_quota', function ($mountPoint, $storage, IMountPoint $mount) use ($quotaIncludeExternal) { // set up quota for home storages, even for other users // which can happen when using sharing - - /** - * @var Storage $storage - */ - if ($storage->instanceOfStorage(HomeObjectStoreStorage::class) || $storage->instanceOfStorage(Home::class)) { - if (is_object($storage->getUser())) { - $user = $storage->getUser(); - return new Quota(['storage' => $storage, 'quotaCallback' => function () use ($user) { - return OC_Util::getUserQuota($user); - }, 'root' => 'files']); - } + if ($mount instanceof HomeMountPoint) { + $user = $mount->getUser(); + return new Quota(['storage' => $storage, 'quotaCallback' => function () use ($user) { + return $user->getQuotaBytes(); + }, 'root' => 'files', 'include_external_storage' => $quotaIncludeExternal]); } return $storage; @@ -191,9 +172,9 @@ class SetupManager { return new PermissionsMask([ 'storage' => $storage, 'mask' => Constants::PERMISSION_ALL & ~( - Constants::PERMISSION_UPDATE | - Constants::PERMISSION_CREATE | - Constants::PERMISSION_DELETE + Constants::PERMISSION_UPDATE + | Constants::PERMISSION_CREATE + | Constants::PERMISSION_DELETE ), ]); } @@ -212,6 +193,8 @@ class SetupManager { } $this->setupUsersComplete[] = $user->getUID(); + $this->eventLogger->start('fs:setup:user:full', 'Setup full filesystem for user'); + if (!isset($this->setupUserMountProviders[$user->getUID()])) { $this->setupUserMountProviders[$user->getUID()] = []; } @@ -220,29 +203,38 @@ class SetupManager { $this->setupForUserWith($user, function () use ($user) { $this->mountProviderCollection->addMountForUser($user, $this->mountManager, function ( - IMountProvider $provider + IMountProvider $provider, ) use ($user) { return !in_array(get_class($provider), $this->setupUserMountProviders[$user->getUID()]); }); }); $this->afterUserFullySetup($user, $previouslySetupProviders); + $this->eventLogger->end('fs:setup:user:full'); } /** * part of the user setup that is run only once per user */ private function oneTimeUserSetup(IUser $user) { - if (in_array($user->getUID(), $this->setupUsers, true)) { + if ($this->isSetupStarted($user)) { return; } $this->setupUsers[] = $user->getUID(); + $this->setupRoot(); + + $this->eventLogger->start('fs:setup:user:onetime', 'Onetime filesystem for user'); + $this->setupBuiltinWrappers(); $prevLogging = Filesystem::logWarningWhenAddingStorageWrapper(false); + // TODO remove hook OC_Hook::emit('OC_Filesystem', 'preSetup', ['user' => $user->getUID()]); + $event = new BeforeFileSystemSetupEvent($user); + $this->eventDispatcher->dispatchTyped($event); + Filesystem::logWarningWhenAddingStorageWrapper($prevLogging); $userDir = '/' . $user->getUID() . '/files'; @@ -250,14 +242,18 @@ class SetupManager { Filesystem::initInternal($userDir); if ($this->lockdownManager->canAccessFilesystem()) { + $this->eventLogger->start('fs:setup:user:home', 'Setup home filesystem for user'); // home mounts are handled separate since we need to ensure this is mounted before we call the other mount providers $homeMount = $this->mountProviderCollection->getHomeMountForUser($user); $this->mountManager->addMount($homeMount); if ($homeMount->getStorageRootId() === -1) { + $this->eventLogger->start('fs:setup:user:home:scan', 'Scan home filesystem for user'); $homeMount->getStorage()->mkdir(''); $homeMount->getStorage()->getScanner()->scan(''); + $this->eventLogger->end('fs:setup:user:home:scan'); } + $this->eventLogger->end('fs:setup:user:home'); } else { $this->mountManager->addMount(new MountPoint( new NullStorage([]), @@ -271,31 +267,39 @@ class SetupManager { } $this->listenForNewMountProviders(); + + $this->eventLogger->end('fs:setup:user:onetime'); } /** * Final housekeeping after a user has been fully setup */ private function afterUserFullySetup(IUser $user, array $previouslySetupProviders): void { + $this->eventLogger->start('fs:setup:user:full:post', 'Housekeeping after user is setup'); $userRoot = '/' . $user->getUID() . '/'; $mounts = $this->mountManager->getAll(); $mounts = array_filter($mounts, function (IMountPoint $mount) use ($userRoot) { - return strpos($mount->getMountPoint(), $userRoot) === 0; + return str_starts_with($mount->getMountPoint(), $userRoot); }); - $allProviders = array_map(function (IMountProvider $provider) { + $allProviders = array_map(function (IMountProvider|IHomeMountProvider|IRootMountProvider $provider) { return get_class($provider); - }, $this->mountProviderCollection->getProviders()); + }, array_merge( + $this->mountProviderCollection->getProviders(), + $this->mountProviderCollection->getHomeProviders(), + $this->mountProviderCollection->getRootProviders(), + )); $newProviders = array_diff($allProviders, $previouslySetupProviders); $mounts = array_filter($mounts, function (IMountPoint $mount) use ($previouslySetupProviders) { return !in_array($mount->getMountProvider(), $previouslySetupProviders); }); - $this->userMountCache->registerMounts($user, $mounts, $newProviders); + $this->registerMounts($user, $mounts, $newProviders); $cacheDuration = $this->config->getSystemValueInt('fs_mount_cache_duration', 5 * 60); if ($cacheDuration > 0) { $this->cache->set($user->getUID(), true, $cacheDuration); $this->fullSetupRequired[$user->getUID()] = false; } + $this->eventLogger->end('fs:setup:user:full:post'); } /** @@ -306,23 +310,19 @@ class SetupManager { * @throws \OC\ServerNotAvailableException */ private function setupForUserWith(IUser $user, callable $mountCallback): void { - $this->setupRoot(); - - if (!$this->isSetupStarted($user)) { - $this->oneTimeUserSetup($user); - } - - $this->eventLogger->start('setup_fs', 'Setup filesystem'); + $this->oneTimeUserSetup($user); if ($this->lockdownManager->canAccessFilesystem()) { $mountCallback(); } + $this->eventLogger->start('fs:setup:user:post-init-mountpoint', 'post_initMountPoints legacy hook'); \OC_Hook::emit('OC_Filesystem', 'post_initMountPoints', ['user' => $user->getUID()]); + $this->eventLogger->end('fs:setup:user:post-init-mountpoint'); $userDir = '/' . $user->getUID() . '/files'; + $this->eventLogger->start('fs:setup:user:setup-hook', 'setup legacy hook'); OC_Hook::emit('OC_Filesystem', 'setup', ['user' => $user->getUID(), 'user_dir' => $userDir]); - - $this->eventLogger->end('setup_fs'); + $this->eventLogger->end('fs:setup:user:setup-hook'); } /** @@ -333,18 +333,19 @@ class SetupManager { if ($this->rootSetup) { return; } - $this->rootSetup = true; - - $this->eventLogger->start('setup_root_fs', 'Setup root filesystem'); $this->setupBuiltinWrappers(); + $this->rootSetup = true; + + $this->eventLogger->start('fs:setup:root', 'Setup root filesystem'); + $rootMounts = $this->mountProviderCollection->getRootMounts(); foreach ($rootMounts as $rootMountProvider) { $this->mountManager->addMount($rootMountProvider); } - $this->eventLogger->end('setup_root_fs'); + $this->eventLogger->end('fs:setup:root'); } /** @@ -354,7 +355,7 @@ class SetupManager { * @return IUser|null */ private function getUserForPath(string $path) { - if (strpos($path, '/__groupfolders') === 0) { + if (str_starts_with($path, '/__groupfolders')) { return null; } elseif (substr_count($path, '/') < 2) { if ($user = $this->userSession->getUser()) { @@ -362,7 +363,7 @@ class SetupManager { } else { return null; } - } elseif (strpos($path, '/appdata_' . \OC_Util::getInstanceId()) === 0 || strpos($path, '/files_external/') === 0) { + } elseif (str_starts_with($path, '/appdata_' . \OC_Util::getInstanceId()) || str_starts_with($path, '/files_external/')) { return null; } else { [, $userId] = explode('/', $path); @@ -391,7 +392,7 @@ class SetupManager { } // for the user's home folder, and includes children we need everything always - if (rtrim($path) === "/" . $user->getUID() . "/files" && $includeChildren) { + if (rtrim($path) === '/' . $user->getUID() . '/files' && $includeChildren) { $this->setupForUser($user); return; } @@ -409,9 +410,10 @@ class SetupManager { return; } - if (!$this->isSetupStarted($user)) { - $this->oneTimeUserSetup($user); - } + $this->oneTimeUserSetup($user); + + $this->eventLogger->start('fs:setup:user:path', "Setup $path filesystem for user"); + $this->eventLogger->start('fs:setup:user:path:find', "Find mountpoint for $path"); $mounts = []; if (!in_array($cachedMount->getMountProvider(), $setupProviders)) { @@ -420,22 +422,26 @@ class SetupManager { $setupProviders[] = $cachedMount->getMountProvider(); $mounts = $this->mountProviderCollection->getUserMountsForProviderClasses($user, [$cachedMount->getMountProvider()]); } else { - $this->logger->debug("mount at " . $cachedMount->getMountPoint() . " has no provider set, performing full setup"); + $this->logger->debug('mount at ' . $cachedMount->getMountPoint() . ' has no provider set, performing full setup'); + $this->eventLogger->end('fs:setup:user:path:find'); $this->setupForUser($user); + $this->eventLogger->end('fs:setup:user:path'); return; } } if ($includeChildren) { $subCachedMounts = $this->userMountCache->getMountsInPath($user, $path); + $this->eventLogger->end('fs:setup:user:path:find'); $needsFullSetup = array_reduce($subCachedMounts, function (bool $needsFullSetup, ICachedMountInfo $cachedMountInfo) { return $needsFullSetup || $cachedMountInfo->getMountProvider() === ''; }, false); if ($needsFullSetup) { - $this->logger->debug("mount has no provider set, performing full setup"); + $this->logger->debug('mount has no provider set, performing full setup'); $this->setupForUser($user); + $this->eventLogger->end('fs:setup:user:path'); return; } else { foreach ($subCachedMounts as $cachedMount) { @@ -446,19 +452,26 @@ class SetupManager { } } } + } else { + $this->eventLogger->end('fs:setup:user:path:find'); } if (count($mounts)) { - $this->userMountCache->registerMounts($user, $mounts, $currentProviders); + $this->registerMounts($user, $mounts, $currentProviders); $this->setupForUserWith($user, function () use ($mounts) { array_walk($mounts, [$this->mountManager, 'addMount']); }); } elseif (!$this->isSetupStarted($user)) { $this->oneTimeUserSetup($user); } + $this->eventLogger->end('fs:setup:user:path'); } private function fullSetupRequired(IUser $user): bool { + if ($this->forceFullSetup) { + return true; + } + // we perform a "cached" setup only after having done the full setup recently // this is also used to trigger a full setup after handling events that are likely // to change the available mounts @@ -488,6 +501,10 @@ class SetupManager { return; } + $this->eventLogger->start('fs:setup:user:providers', 'Setup filesystem for ' . implode(', ', $providers)); + + $this->oneTimeUserSetup($user); + // home providers are always used $providers = array_filter($providers, function (string $provider) { return !is_subclass_of($provider, IHomeMountProvider::class); @@ -504,16 +521,18 @@ class SetupManager { if (!$this->isSetupStarted($user)) { $this->oneTimeUserSetup($user); } + $this->eventLogger->end('fs:setup:user:providers'); return; } else { $this->setupUserMountProviders[$user->getUID()] = array_merge($setupProviders, $providers); $mounts = $this->mountProviderCollection->getUserMountsForProviderClasses($user, $providers); } - $this->userMountCache->registerMounts($user, $mounts, $providers); + $this->registerMounts($user, $mounts, $providers); $this->setupForUserWith($user, function () use ($mounts) { array_walk($mounts, [$this->mountManager, 'addMount']); }); + $this->eventLogger->end('fs:setup:user:providers'); } public function tearDown() { @@ -533,7 +552,7 @@ class SetupManager { if (!$this->listeningForProviders) { $this->listeningForProviders = true; $this->mountProviderCollection->listen('\OC\Files\Config', 'registerMountProvider', function ( - IMountProvider $provider + IMountProvider $provider, ) { foreach ($this->setupUsers as $userId) { $user = $this->userManager->get($userId); @@ -559,7 +578,7 @@ class SetupManager { $this->eventDispatcher->addListener(ShareCreatedEvent::class, function (ShareCreatedEvent $event) { $this->cache->remove($event->getShare()->getSharedWith()); }); - $this->eventDispatcher->addListener(InvalidateMountCacheEvent::class, function (InvalidateMountCacheEvent $event + $this->eventDispatcher->addListener(InvalidateMountCacheEvent::class, function (InvalidateMountCacheEvent $event, ) { if ($user = $event->getUser()) { $this->cache->remove($user->getUID()); @@ -581,4 +600,10 @@ class SetupManager { }); } } + + private function registerMounts(IUser $user, array $mounts, ?array $mountProviderClasses = null): void { + if ($this->lockdownManager->canAccessFilesystem()) { + $this->userMountCache->registerMounts($user, $mounts, $mountProviderClasses); + } + } } diff --git a/lib/private/Files/SetupManagerFactory.php b/lib/private/Files/SetupManagerFactory.php index 1d9efbd411f..d2fe978fa9e 100644 --- a/lib/private/Files/SetupManagerFactory.php +++ b/lib/private/Files/SetupManagerFactory.php @@ -2,27 +2,14 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files; +use OC\Share20\ShareDisableChecker; +use OCP\App\IAppManager; use OCP\Diagnostics\IEventLogger; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Config\IMountProviderCollection; @@ -36,40 +23,22 @@ use OCP\Lockdown\ILockdownManager; use Psr\Log\LoggerInterface; class SetupManagerFactory { - private IEventLogger $eventLogger; - private IMountProviderCollection $mountProviderCollection; - private IUserManager $userManager; - private IEventDispatcher $eventDispatcher; - private IUserMountCache $userMountCache; - private ILockdownManager $lockdownManager; - private IUserSession $userSession; private ?SetupManager $setupManager; - private ICacheFactory $cacheFactory; - private LoggerInterface $logger; - private IConfig $config; public function __construct( - IEventLogger $eventLogger, - IMountProviderCollection $mountProviderCollection, - IUserManager $userManager, - IEventDispatcher $eventDispatcher, - IUserMountCache $userMountCache, - ILockdownManager $lockdownManager, - IUserSession $userSession, - ICacheFactory $cacheFactory, - LoggerInterface $logger, - IConfig $config + private IEventLogger $eventLogger, + private IMountProviderCollection $mountProviderCollection, + private IUserManager $userManager, + private IEventDispatcher $eventDispatcher, + private IUserMountCache $userMountCache, + private ILockdownManager $lockdownManager, + private IUserSession $userSession, + private ICacheFactory $cacheFactory, + private LoggerInterface $logger, + private IConfig $config, + private ShareDisableChecker $shareDisableChecker, + private IAppManager $appManager, ) { - $this->eventLogger = $eventLogger; - $this->mountProviderCollection = $mountProviderCollection; - $this->userManager = $userManager; - $this->eventDispatcher = $eventDispatcher; - $this->userMountCache = $userMountCache; - $this->lockdownManager = $lockdownManager; - $this->userSession = $userSession; - $this->cacheFactory = $cacheFactory; - $this->logger = $logger; - $this->config = $config; $this->setupManager = null; } @@ -86,7 +55,9 @@ class SetupManagerFactory { $this->userSession, $this->cacheFactory, $this->logger, - $this->config + $this->config, + $this->shareDisableChecker, + $this->appManager, ); } return $this->setupManager; diff --git a/lib/private/Files/SimpleFS/NewSimpleFile.php b/lib/private/Files/SimpleFS/NewSimpleFile.php index e2d1ae274b1..d0986592c03 100644 --- a/lib/private/Files/SimpleFS/NewSimpleFile.php +++ b/lib/private/Files/SimpleFS/NewSimpleFile.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020 Robin Appelman <robin@icewind.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\SimpleFS; @@ -56,7 +38,7 @@ class NewSimpleFile implements ISimpleFile { /** * Get the size in bytes */ - public function getSize(): int { + public function getSize(): int|float { if ($this->file) { return $this->file->getSize(); } else { @@ -196,7 +178,7 @@ class NewSimpleFile implements ISimpleFile { /** * Open the file as stream for reading, resulting resource can be operated as stream like the result from php's own fopen * - * @return resource + * @return resource|false * @throws \OCP\Files\NotPermittedException * @since 14.0.0 */ diff --git a/lib/private/Files/SimpleFS/SimpleFile.php b/lib/private/Files/SimpleFS/SimpleFile.php index a2571ac50e8..d9c1b47d2f1 100644 --- a/lib/private/Files/SimpleFS/SimpleFile.php +++ b/lib/private/Files/SimpleFS/SimpleFile.php @@ -1,33 +1,17 @@ <?php + /** - * @copyright 2016 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Julius Härtl <jus@bitgrid.net> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\SimpleFS; use OCP\Files\File; +use OCP\Files\GenericFileException; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\Files\SimpleFS\ISimpleFile; +use OCP\Lock\LockedException; class SimpleFile implements ISimpleFile { private File $file; @@ -46,7 +30,7 @@ class SimpleFile implements ISimpleFile { /** * Get the size in bytes */ - public function getSize(): int { + public function getSize(): int|float { return $this->file->getSize(); } @@ -67,8 +51,10 @@ class SimpleFile implements ISimpleFile { /** * Get the content * - * @throws NotPermittedException + * @throws GenericFileException + * @throws LockedException * @throws NotFoundException + * @throws NotPermittedException */ public function getContent(): string { $result = $this->file->getContent(); @@ -84,8 +70,10 @@ class SimpleFile implements ISimpleFile { * Overwrite the file * * @param string|resource $data - * @throws NotPermittedException + * @throws GenericFileException + * @throws LockedException * @throws NotFoundException + * @throws NotPermittedException */ public function putContent($data): void { try { @@ -150,7 +138,7 @@ class SimpleFile implements ISimpleFile { /** * Open the file as stream for reading, resulting resource can be operated as stream like the result from php's own fopen * - * @return resource + * @return resource|false * @throws \OCP\Files\NotPermittedException * @since 14.0.0 */ diff --git a/lib/private/Files/SimpleFS/SimpleFolder.php b/lib/private/Files/SimpleFS/SimpleFolder.php index 4d24aa138c1..62f3db25e9b 100644 --- a/lib/private/Files/SimpleFS/SimpleFolder.php +++ b/lib/private/Files/SimpleFS/SimpleFolder.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright 2016 Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\SimpleFS; @@ -28,8 +10,8 @@ use OCP\Files\File; use OCP\Files\Folder; use OCP\Files\Node; use OCP\Files\NotFoundException; -use OCP\Files\SimpleFS\ISimpleFolder; use OCP\Files\SimpleFS\ISimpleFile; +use OCP\Files\SimpleFS\ISimpleFolder; class SimpleFolder implements ISimpleFolder { /** @var Folder */ diff --git a/lib/private/Files/Storage/Common.php b/lib/private/Files/Storage/Common.php index 0c121feb11e..2dc359169d7 100644 --- a/lib/private/Files/Storage/Common.php +++ b/lib/private/Files/Storage/Common.php @@ -1,68 +1,43 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Greta Doci <gretadoci@gmail.com> - * @author hkjolhede <hkjolhede@gmail.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Martin Mattel <martin.mattel@diemattels.at> - * @author Michael Gapczynski <GapczynskiM@gmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Roland Tapken <roland@bitarbeiter.net> - * @author Sam Tuke <mail@samtuke.com> - * @author scambra <sergio@entrecables.com> - * @author Stefan Weil <sw@weilnetz.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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\EmptyFileNameException; -use OCP\Files\FileNameTooLongException; +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\InvalidCharacterInPathException; -use OCP\Files\InvalidDirectoryException; +use OCP\Files\IFilenameValidator; use OCP\Files\InvalidPathException; -use OCP\Files\ReservedWordException; +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; /** @@ -76,85 +51,75 @@ use Psr\Log\LoggerInterface; * 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 { +abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage, IConstructableStorage { use LocalTempFileTrait; - protected $cache; - protected $scanner; - protected $watcher; - protected $propagator; + protected ?Cache $cache = null; + protected ?Scanner $scanner = null; + protected ?Watcher $watcher = null; + protected ?Propagator $propagator = null; protected $storageCache; - protected $updater; + protected ?Updater $updater = null; - protected $mountOptions = []; + protected array $mountOptions = []; protected $owner = null; - /** @var ?bool */ - private $shouldLogLocks = null; - /** @var ?LoggerInterface */ - private $logger; + private ?bool $shouldLogLocks = null; + private ?LoggerInterface $logger = null; + private ?IFilenameValidator $filenameValidator = null; - public function __construct($parameters) { + public function __construct(array $parameters) { } - /** - * Remove a file or folder - * - * @param string $path - * @return bool - */ - protected function remove($path) { - if ($this->is_dir($path)) { - return $this->rmdir($path); - } elseif ($this->is_file($path)) { - return $this->unlink($path); - } else { - return false; + 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($path) { + public function is_dir(string $path): bool { return $this->filetype($path) === 'dir'; } - public function is_file($path) { + public function is_file(string $path): bool { return $this->filetype($path) === 'file'; } - public function filesize($path) { + public function filesize(string $path): int|float|false { if ($this->is_dir($path)) { return 0; //by definition } else { $stat = $this->stat($path); - if (isset($stat['size'])) { - return $stat['size']; - } else { - return 0; - } + return isset($stat['size']) ? $stat['size'] : 0; } } - public function isReadable($path) { + 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($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($path) { + public function isCreatable(string $path): bool { if ($this->is_dir($path) && $this->isUpdatable($path)) { return true; } return false; } - public function isDeletable($path) { + public function isDeletable(string $path): bool { if ($path === '' || $path === '/') { return $this->isUpdatable($path); } @@ -162,11 +127,11 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { return $this->isUpdatable($parent) && $this->isUpdatable($path); } - public function isSharable($path) { + public function isSharable(string $path): bool { return $this->isReadable($path); } - public function getPermissions($path) { + public function getPermissions(string $path): int { $permissions = 0; if ($this->isCreatable($path)) { $permissions |= \OCP\Constants::PERMISSION_CREATE; @@ -186,7 +151,7 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { return $permissions; } - public function filemtime($path) { + public function filemtime(string $path): int|false { $stat = $this->stat($path); if (isset($stat['mtime']) && $stat['mtime'] > 0) { return $stat['mtime']; @@ -195,8 +160,8 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { } } - public function file_get_contents($path) { - $handle = $this->fopen($path, "r"); + public function file_get_contents(string $path): string|false { + $handle = $this->fopen($path, 'r'); if (!$handle) { return false; } @@ -205,27 +170,30 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { return $data; } - public function file_put_contents($path, $data) { - $handle = $this->fopen($path, "w"); + 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($source, $target) { + 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($source, $target) { + 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)) { + while (($file = readdir($dir)) !== false) { if (!Filesystem::isIgnoredDir($file)) { if (!$this->copy($source . '/' . $file, $target . '/' . $file)) { closedir($dir); @@ -238,16 +206,16 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { } else { $sourceStream = $this->fopen($source, 'r'); $targetStream = $this->fopen($target, 'w'); - [, $result] = \OC_Helper::streamCopy($sourceStream, $targetStream); + [, $result] = Files::streamCopy($sourceStream, $targetStream, true); if (!$result) { - \OCP\Server::get(LoggerInterface::class)->warning("Failed to write data while copying $source to $target"); + Server::get(LoggerInterface::class)->warning("Failed to write data while copying $source to $target"); } $this->removeCachedFile($target); return $result; } } - public function getMimeType($path) { + public function getMimeType(string $path): string|false { if ($this->is_dir($path)) { return 'httpd/unix-directory'; } elseif ($this->file_exists($path)) { @@ -257,31 +225,26 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { } } - public function hash($type, $path, $raw = 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 search($query) { - return $this->searchInDir($query); - } - - public function getLocalFile($path) { + public function getLocalFile(string $path): string|false { return $this->getCachedFile($path); } - /** - * @param string $path - * @param string $target - */ - private function addLocalFolder($path, $target) { + private function addLocalFolder(string $path, string $target): void { $dh = $this->opendir($path); if (is_resource($dh)) { while (($file = readdir($dh)) !== false) { - if (!\OC\Files\Filesystem::isIgnoredDir($file)) { + if (!Filesystem::isIgnoredDir($file)) { if ($this->is_dir($path . '/' . $file)) { mkdir($target . '/' . $file); $this->addLocalFolder($path . '/' . $file, $target . '/' . $file); @@ -294,17 +257,12 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { } } - /** - * @param string $query - * @param string $dir - * @return array - */ - protected function searchInDir($query, $dir = '') { + protected function searchInDir(string $query, string $dir = ''): array { $files = []; $dh = $this->opendir($dir); if (is_resource($dh)) { while (($item = readdir($dh)) !== false) { - if (\OC\Files\Filesystem::isIgnoredDir($item)) { + if (Filesystem::isIgnoredDir($item)) { continue; } if (strstr(strtolower($item), strtolower($query)) !== false) { @@ -320,97 +278,98 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { } /** + * @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. - * - * @param string $path - * @param int $time - * @return bool */ - public function hasUpdated($path, $time) { + public function hasUpdated(string $path, int $time): bool { return $this->filemtime($path) > $time; } - public function getCache($path = '', $storage = null) { + 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); + $storage->cache = new Cache($storage, $this->getCacheDependencies()); } return $storage->cache; } - public function getScanner($path = '', $storage = null) { + 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($path = '', $storage = null) { + public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher { if (!$storage) { $storage = $this; } if (!isset($this->watcher)) { $this->watcher = new Watcher($storage); - $globalPolicy = \OC::$server->getConfig()->getSystemValue('filesystem_check_changes', Watcher::CHECK_NEVER); + $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; } - /** - * get a propagator instance for the cache - * - * @param \OC\Files\Storage\Storage (optional) the storage to pass to the watcher - * @return \OC\Files\Cache\Propagator - */ - public function getPropagator($storage = null) { + 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 = \OC::$server->getSystemConfig(); - $storage->propagator = new Propagator($storage, \OC::$server->getDatabaseConnection(), ['appdata_' . $config->getValue('instanceid')]); + $config = Server::get(IConfig::class); + $storage->propagator = new Propagator($storage, \OC::$server->getDatabaseConnection(), ['appdata_' . $config->getSystemValueString('instanceid')]); } return $storage->propagator; } - public function getUpdater($storage = null) { + 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($storage = null) { - if (!$storage) { - $storage = $this; - } - if (!isset($this->storageCache)) { - $this->storageCache = new \OC\Files\Cache\Storage($storage); - } - return $this->storageCache; + public function getStorageCache(?IStorage $storage = null): \OC\Files\Cache\Storage { + /** @var Cache $cache */ + $cache = $this->getCache(storage: $storage); + return $cache->getStorageCache(); } - /** - * get the owner of a path - * - * @param string $path The path to get the owner - * @return string|false uid or false - */ - public function getOwner($path) { + public function getOwner(string $path): string|false { if ($this->owner === null) { $this->owner = \OC_User::getUser(); } @@ -418,13 +377,7 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { return $this->owner; } - /** - * get the ETag for a file or folder - * - * @param string $path - * @return string - */ - public function getETag($path) { + public function getETag(string $path): string|false { return uniqid(); } @@ -435,8 +388,8 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { * @param string $path The path to clean * @return string cleaned path */ - public function cleanPath($path) { - if (strlen($path) == 0 or $path[0] != '/') { + public function cleanPath(string $path): string { + if (strlen($path) == 0 || $path[0] != '/') { $path = '/' . $path; } @@ -454,39 +407,28 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { /** * Test a storage for availability - * - * @return bool */ - public function test() { + public function test(): bool { try { if ($this->stat('')) { return true; } - \OC::$server->get(LoggerInterface::class)->info("External storage not available: stat() failed"); + Server::get(LoggerInterface::class)->info('External storage not available: stat() failed'); return false; } catch (\Exception $e) { - \OC::$server->get(LoggerInterface::class)->warning( - "External storage not available: " . $e->getMessage(), + Server::get(LoggerInterface::class)->warning( + 'External storage not available: ' . $e->getMessage(), ['exception' => $e] ); return false; } } - /** - * get the free space in the storage - * - * @param string $path - * @return int|false - */ - public function free_space($path) { + public function free_space(string $path): int|float|false { return \OCP\Files\FileInfo::SPACE_UNKNOWN; } - /** - * {@inheritdoc} - */ - public function isLocal() { + public function isLocal(): bool { // the common implementation returns a temporary file by // default, which is not local return false; @@ -494,11 +436,8 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { /** * Check if the storage is an instance of $class or is a wrapper for a storage that is an instance of $class - * - * @param string $class - * @return bool */ - public function instanceOfStorage($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'; @@ -510,105 +449,49 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { * 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. - * - * @param string $path - * @return array|false */ - public function getDirectDownload($path) { + public function getDirectDownload(string $path): array|false { return []; } - /** - * @inheritdoc - * @throws InvalidPathException - */ - public function verifyPath($path, $fileName) { - // verify empty and dot files - $trimmed = trim($fileName); - if ($trimmed === '') { - throw new EmptyFileNameException(); - } - - if (\OC\Files\Filesystem::isIgnoredDir($trimmed)) { - throw new InvalidDirectoryException(); - } + public function verifyPath(string $path, string $fileName): void { + $this->getFilenameValidator() + ->validateFilename($fileName); - if (!\OC::$server->getDatabaseConnection()->supports4ByteText()) { - // verify database - e.g. mysql only 3-byte chars - if (preg_match('%(?: - \xF0[\x90-\xBF][\x80-\xBF]{2} # planes 1-3 - | [\xF1-\xF3][\x80-\xBF]{3} # planes 4-15 - | \xF4[\x80-\x8F][\x80-\xBF]{2} # plane 16 -)%xs', $fileName)) { - throw new InvalidCharacterInPathException(); + // 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); + } } } - - // 255 characters is the limit on common file systems (ext/xfs) - // oc_filecache has a 250 char length limit for the filename - if (isset($fileName[250])) { - throw new FileNameTooLongException(); - } - - // NOTE: $path will remain unverified for now - $this->verifyPosixPath($fileName); } /** - * @param string $fileName - * @throws InvalidPathException + * Get the filename validator + * (cached for performance) */ - protected function verifyPosixPath($fileName) { - $this->scanForInvalidCharacters($fileName, "\\/"); - $fileName = trim($fileName); - $reservedNames = ['*']; - if (in_array($fileName, $reservedNames)) { - throw new ReservedWordException(); + protected function getFilenameValidator(): IFilenameValidator { + if ($this->filenameValidator === null) { + $this->filenameValidator = Server::get(IFilenameValidator::class); } + return $this->filenameValidator; } - /** - * @param string $fileName - * @param string $invalidChars - * @throws InvalidPathException - */ - private function scanForInvalidCharacters($fileName, $invalidChars) { - foreach (str_split($invalidChars) as $char) { - if (strpos($fileName, $char) !== false) { - throw new InvalidCharacterInPathException(); - } - } - - $sanitizedFileName = filter_var($fileName, FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW); - if ($sanitizedFileName !== $fileName) { - throw new InvalidCharacterInPathException(); - } - } - - /** - * @param array $options - */ - public function setMountOptions(array $options) { + public function setMountOptions(array $options): void { $this->mountOptions = $options; } - /** - * @param string $name - * @param mixed $default - * @return mixed - */ - public function getMountOption($name, $default = null) { - return isset($this->mountOptions[$name]) ? $this->mountOptions[$name] : $default; + public function getMountOption(string $name, mixed $default = null): mixed { + return $this->mountOptions[$name] ?? $default; } - /** - * @param IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @param bool $preserveMtime - * @return bool - */ - public function copyFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath, $preserveMtime = false) { + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath, bool $preserveMtime = false): bool { if ($sourceStorage === $this) { return $this->copy($sourceInternalPath, $targetInternalPath); } @@ -618,7 +501,7 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { $result = $this->mkdir($targetInternalPath); if (is_resource($dh)) { $result = true; - while ($result and ($file = readdir($dh)) !== false) { + while ($result && ($file = readdir($dh)) !== false) { if (!Filesystem::isIgnoredDir($file)) { $result &= $this->copyFromStorage($sourceStorage, $sourceInternalPath . '/' . $file, $targetInternalPath . '/' . $file); } @@ -632,7 +515,7 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { $this->writeStream($targetInternalPath, $source); $result = true; } catch (\Exception $e) { - \OC::$server->get(LoggerInterface::class)->warning('Failed to copy stream to storage', ['exception' => $e]); + Server::get(LoggerInterface::class)->warning('Failed to copy stream to storage', ['exception' => $e]); } } @@ -653,14 +536,11 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { /** * Check if a storage is the same as the current one, including wrapped storages - * - * @param IStorage $storage - * @return bool */ private function isSameStorage(IStorage $storage): bool { while ($storage->instanceOfStorage(Wrapper::class)) { /** - * @var Wrapper $sourceStorage + * @var Wrapper $storage */ $storage = $storage->getWrapperStorage(); } @@ -668,14 +548,11 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { return $storage === $this; } - /** - * @param IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @return bool - */ - public function moveFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { - if ($this->isSameStorage($sourceStorage)) { + 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)) { /** @@ -694,19 +571,27 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { $result = $this->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath, true); if ($result) { - if ($sourceStorage->is_dir($sourceInternalPath)) { - $result = $result && $sourceStorage->rmdir($sourceInternalPath); - } else { - $result = $result && $sourceStorage->unlink($sourceInternalPath); + 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; } - /** - * @inheritdoc - */ - public function getMetaData($path) { + public function getMetaData(string $path): ?array { if (Filesystem::isFileBlacklisted($path)) { throw new ForbiddenException('Invalid path: ' . $path, false); } @@ -736,13 +621,7 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { return $data; } - /** - * @param string $path - * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE - * @param \OCP\Lock\ILockingProvider $provider - * @throws \OCP\Lock\LockedException - */ - public function acquireLock($path, $type, ILockingProvider $provider) { + public function acquireLock(string $path, int $type, ILockingProvider $provider): void { $logger = $this->getLockLogger(); if ($logger) { $typeString = ($type === ILockingProvider::LOCK_SHARED) ? 'shared' : 'exclusive'; @@ -761,6 +640,7 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { 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]); } @@ -768,13 +648,7 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { } } - /** - * @param string $path - * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE - * @param \OCP\Lock\ILockingProvider $provider - * @throws \OCP\Lock\LockedException - */ - public function releaseLock($path, $type, ILockingProvider $provider) { + public function releaseLock(string $path, int $type, ILockingProvider $provider): void { $logger = $this->getLockLogger(); if ($logger) { $typeString = ($type === ILockingProvider::LOCK_SHARED) ? 'shared' : 'exclusive'; @@ -793,6 +667,7 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { 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]); } @@ -800,13 +675,7 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { } } - /** - * @param string $path - * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE - * @param \OCP\Lock\ILockingProvider $provider - * @throws \OCP\Lock\LockedException - */ - public function changeLock($path, $type, ILockingProvider $provider) { + public function changeLock(string $path, int $type, ILockingProvider $provider): void { $logger = $this->getLockLogger(); if ($logger) { $typeString = ($type === ILockingProvider::LOCK_SHARED) ? 'shared' : 'exclusive'; @@ -825,6 +694,7 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { 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]); } @@ -834,8 +704,8 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { private function getLockLogger(): ?LoggerInterface { if (is_null($this->shouldLogLocks)) { - $this->shouldLogLocks = \OC::$server->getConfig()->getSystemValue('filelocking.debug', false); - $this->logger = $this->shouldLogLocks ? \OC::$server->get(LoggerInterface::class) : null; + $this->shouldLogLocks = Server::get(IConfig::class)->getSystemValueBool('filelocking.debug', false); + $this->logger = $this->shouldLogLocks ? Server::get(LoggerInterface::class) : null; } return $this->logger; } @@ -843,41 +713,31 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { /** * @return array [ available, last_checked ] */ - public function getAvailability() { + public function getAvailability(): array { return $this->getStorageCache()->getAvailability(); } - /** - * @param bool $isAvailable - */ - public function setAvailability($isAvailable) { + public function setAvailability(bool $isAvailable): void { $this->getStorageCache()->setAvailability($isAvailable); } - /** - * @return bool - */ - public function needsPartFile() { + public function setOwner(?string $user): void { + $this->owner = $user; + } + + public function needsPartFile(): bool { return true; } - /** - * fallback implementation - * - * @param string $path - * @param resource $stream - * @param int $size - * @return int - */ - public function writeStream(string $path, $stream, int $size = null): int { + 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] = \OC_Helper::streamCopy($stream, $target); + [$count, $result] = Files::streamCopy($stream, $target, true); if (!$result) { - throw new GenericFileException("Failed to copy stream"); + throw new GenericFileException('Failed to copy stream'); } } finally { fclose($target); @@ -886,8 +746,13 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage { return $count; } - public function getDirectoryContent($directory): \Traversable { + 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) { diff --git a/lib/private/Files/Storage/CommonTest.php b/lib/private/Files/Storage/CommonTest.php index 3800bba2b52..da796130899 100644 --- a/lib/private/Files/Storage/CommonTest.php +++ b/lib/private/Files/Storage/CommonTest.php @@ -1,30 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bart Visscher <bartv@thisnet.nl> - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Felix Moeller <mail@felixmoeller.de> - * @author Michael Gapczynski <GapczynskiM@gmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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; @@ -35,47 +14,47 @@ class CommonTest extends \OC\Files\Storage\Common { */ private $storage; - public function __construct($params) { - $this->storage = new \OC\Files\Storage\Local($params); + public function __construct(array $parameters) { + $this->storage = new \OC\Files\Storage\Local($parameters); } - public function getId() { - return 'test::'.$this->storage->getId(); + public function getId(): string { + return 'test::' . $this->storage->getId(); } - public function mkdir($path) { + public function mkdir(string $path): bool { return $this->storage->mkdir($path); } - public function rmdir($path) { + public function rmdir(string $path): bool { return $this->storage->rmdir($path); } - public function opendir($path) { + public function opendir(string $path) { return $this->storage->opendir($path); } - public function stat($path) { + public function stat(string $path): array|false { return $this->storage->stat($path); } - public function filetype($path) { + public function filetype(string $path): string|false { return @$this->storage->filetype($path); } - public function isReadable($path) { + public function isReadable(string $path): bool { return $this->storage->isReadable($path); } - public function isUpdatable($path) { + public function isUpdatable(string $path): bool { return $this->storage->isUpdatable($path); } - public function file_exists($path) { + public function file_exists(string $path): bool { return $this->storage->file_exists($path); } - public function unlink($path) { + public function unlink(string $path): bool { return $this->storage->unlink($path); } - public function fopen($path, $mode) { + public function fopen(string $path, string $mode) { return $this->storage->fopen($path, $mode); } - public function free_space($path) { + public function free_space(string $path): int|float|false { return $this->storage->free_space($path); } - public function touch($path, $mtime = null) { + 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 index fcb07cb9748..2d166b5438d 100644 --- a/lib/private/Files/Storage/DAV.php +++ b/lib/private/Files/Storage/DAV.php @@ -1,39 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Carlos Cerrillo <ccerrillo@gmail.com> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Michael Gapczynski <GapczynskiM@gmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Philipp Kapfer <philipp.kapfer@gmx.at> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Tigran Mkrtchyan <tigran.mkrtchyan@desy.de> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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; @@ -44,18 +14,25 @@ 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 Psr\Log\LoggerInterface; +use Sabre\HTTP\RequestInterface; /** * Class DAV @@ -87,33 +64,50 @@ class DAV extends Common { 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 $params + * @param array $parameters * @throws \Exception */ - public function __construct($params) { + public function __construct(array $parameters) { $this->statCache = new ArrayCache(); - $this->httpClientService = \OC::$server->getHTTPClientService(); - if (isset($params['host']) && isset($params['user']) && isset($params['password'])) { - $host = $params['host']; + $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 (substr($host, 0, 8) == "https://") { + if (str_starts_with($host, 'https://')) { $host = substr($host, 8); - } elseif (substr($host, 0, 7) == "http://") { + } elseif (str_starts_with($host, 'http://')) { $host = substr($host, 7); } $this->host = $host; - $this->user = $params['user']; - $this->password = $params['password']; - if (isset($params['authType'])) { - $this->authType = $params['authType']; + $this->user = $parameters['user']; + $this->password = $parameters['password']; + if (isset($parameters['authType'])) { + $this->authType = $parameters['authType']; } - if (isset($params['secure'])) { - if (is_string($params['secure'])) { - $this->secure = ($params['secure'] === 'true'); + if (isset($parameters['secure'])) { + if (is_string($parameters['secure'])) { + $this->secure = ($parameters['secure'] === 'true'); } else { - $this->secure = (bool)$params['secure']; + $this->secure = (bool)$parameters['secure']; } } else { $this->secure = false; @@ -122,15 +116,20 @@ class DAV extends Common { // inject mock for testing $this->certManager = \OC::$server->getCertificateManager(); } - $this->root = $params['root'] ?? '/'; + $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() { + protected function init(): void { if ($this->ready) { return; } @@ -145,7 +144,7 @@ class DAV extends Common { $settings['authType'] = $this->authType; } - $proxy = \OC::$server->getConfig()->getSystemValue('proxy', ''); + $proxy = Server::get(IConfig::class)->getSystemValueString('proxy', ''); if ($proxy !== '') { $settings['proxy'] = $proxy; } @@ -162,32 +161,41 @@ class DAV extends Common { $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() { + public function clearStatCache(): void { $this->statCache->clear(); } - /** {@inheritdoc} */ - public function getId() { + public function getId(): string { return 'webdav::' . $this->user . '@' . $this->host . '/' . $this->root; } - /** {@inheritdoc} */ - public function createBaseUri() { + public function createBaseUri(): string { $baseUri = 'http'; if ($this->secure) { $baseUri .= 's'; } - $baseUri .= '://' . $this->host . $this->root; + $baseUri .= '://' . $this->host . $this->encodePath($this->root); return $baseUri; } - /** {@inheritdoc} */ - public function mkdir($path) { + public function mkdir(string $path): bool { $this->init(); $path = $this->cleanPath($path); $result = $this->simpleResponse('MKCOL', $path, null, 201); @@ -197,8 +205,7 @@ class DAV extends Common { return $result; } - /** {@inheritdoc} */ - public function rmdir($path) { + public function rmdir(string $path): bool { $this->init(); $path = $this->cleanPath($path); // FIXME: some WebDAV impl return 403 when trying to DELETE @@ -209,36 +216,16 @@ class DAV extends Common { return $result; } - /** {@inheritdoc} */ - public function opendir($path) { + public function opendir(string $path) { $this->init(); $path = $this->cleanPath($path); try { - $response = $this->client->propFind( - $this->encodePath($path), - ['{DAV:}getetag'], - 1 - ); - if ($response === false) { - return false; - } - $content = []; - $files = array_keys($response); - array_shift($files); //the first entry is the current directory - - if (!$this->statCache->hasKey($path)) { - $this->statCache->set($path, true); - } - foreach ($files as $file) { - $file = urldecode($file); - // do not store the real entry, we might not have all properties - if (!$this->statCache->hasKey($path)) { - $this->statCache->set($file, true); - } - $file = basename($file); - $content[] = $file; + $content = $this->getDirectoryContent($path); + $files = []; + foreach ($content as $child) { + $files[] = $child['name']; } - return IteratorDirectory::wrap($content); + return IteratorDirectory::wrap($files); } catch (\Exception $e) { $this->convertException($e, $path); } @@ -253,38 +240,30 @@ class DAV extends Common { * * @param string $path path to propfind * - * @return array|boolean propfind response or false if the entry was not found + * @return array|false propfind response or false if the entry was not found * * @throws ClientHttpException */ - protected function propfind($path) { + 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), - [ - '{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', - ] + 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); - return false; + } else { + $this->convertException($e, $path); } - $this->convertException($e, $path); } catch (\Exception $e) { $this->convertException($e, $path); } @@ -294,27 +273,25 @@ class DAV extends Common { return $response; } - /** {@inheritdoc} */ - public function filetype($path) { + public function filetype(string $path): string|false { try { $response = $this->propfind($path); if ($response === false) { return false; } $responseType = []; - if (isset($response["{DAV:}resourcetype"])) { + if (isset($response['{DAV:}resourcetype'])) { /** @var ResourceType[] $response */ - $responseType = $response["{DAV:}resourcetype"]->getValue(); + $responseType = $response['{DAV:}resourcetype']->getValue(); } - return (count($responseType) > 0 and $responseType[0] == "{DAV:}collection") ? 'dir' : 'file'; + return (count($responseType) > 0 && $responseType[0] == '{DAV:}collection') ? 'dir' : 'file'; } catch (\Exception $e) { $this->convertException($e, $path); } return false; } - /** {@inheritdoc} */ - public function file_exists($path) { + public function file_exists(string $path): bool { try { $path = $this->cleanPath($path); $cachedState = $this->statCache->get($path); @@ -332,8 +309,7 @@ class DAV extends Common { return false; } - /** {@inheritdoc} */ - public function unlink($path) { + public function unlink(string $path): bool { $this->init(); $path = $this->cleanPath($path); $result = $this->simpleResponse('DELETE', $path, null, 204); @@ -342,8 +318,7 @@ class DAV extends Common { return $result; } - /** {@inheritdoc} */ - public function fopen($path, $mode) { + public function fopen(string $path, string $mode) { $this->init(); $path = $this->cleanPath($path); switch ($mode) { @@ -354,7 +329,9 @@ class DAV extends Common { ->newClient() ->get($this->createBaseUri() . $this->encodePath($path), [ 'auth' => [$this->user, $this->password], - 'stream' => true + '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 @@ -369,11 +346,17 @@ class DAV extends Common { if ($response->getStatusCode() === Http::STATUS_LOCKED) { throw new \OCP\Lock\LockedException($path); } else { - \OC::$server->get(LoggerInterface::class)->error('Guzzle get returned status code ' . $response->getStatusCode(), ['app' => 'webdav client']); + $this->logger->error('Guzzle get returned status code ' . $response->getStatusCode(), ['app' => 'webdav client']); } } - return $response->getBody(); + $content = $response->getBody(); + + if ($content === null || is_string($content)) { + return false; + } + + return $content; case 'w': case 'wb': case 'a': @@ -397,7 +380,7 @@ class DAV extends Common { if (!$this->isUpdatable($path)) { return false; } - if ($mode === 'w' or $mode === 'w+') { + if ($mode === 'w' || $mode === 'w+') { $tmpFile = $tempManager->getTemporaryFile($ext); } else { $tmpFile = $this->getCachedFile($path); @@ -413,18 +396,16 @@ class DAV extends Common { $this->writeBack($tmpFile, $path); }); } + + return false; } - /** - * @param string $tmpFile - */ - public function writeBack($tmpFile, $path) { + public function writeBack(string $tmpFile, string $path): void { $this->uploadFile($tmpFile, $path); unlink($tmpFile); } - /** {@inheritdoc} */ - public function free_space($path) { + public function free_space(string $path): int|float|false { $this->init(); $path = $this->cleanPath($path); try { @@ -433,7 +414,7 @@ class DAV extends Common { return FileInfo::SPACE_UNKNOWN; } if (isset($response['{DAV:}quota-available-bytes'])) { - return (int)$response['{DAV:}quota-available-bytes']; + return Util::numericToNumber($response['{DAV:}quota-available-bytes']); } else { return FileInfo::SPACE_UNKNOWN; } @@ -442,8 +423,7 @@ class DAV extends Common { } } - /** {@inheritdoc} */ - public function touch($path, $mtime = null) { + public function touch(string $path, ?int $mtime = null): bool { $this->init(); if (is_null($mtime)) { $mtime = time(); @@ -457,9 +437,6 @@ class DAV extends Common { $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 ($response === false) { - return false; - } if (isset($response['{DAV:}getlastmodified'])) { $remoteMtime = strtotime($response['{DAV:}getlastmodified']); if ($remoteMtime !== $mtime) { @@ -483,23 +460,14 @@ class DAV extends Common { return true; } - /** - * @param string $path - * @param mixed $data - * @return int|false - */ - public function file_put_contents($path, $data) { + 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; } - /** - * @param string $path - * @param string $target - */ - protected function uploadFile($path, $target) { + protected function uploadFile(string $path, string $target): void { $this->init(); // invalidate @@ -511,14 +479,15 @@ class DAV extends Common { ->newClient() ->put($this->createBaseUri() . $this->encodePath($target), [ 'body' => $source, - 'auth' => [$this->user, $this->password] + 'auth' => [$this->user, $this->password], + // set upload timeout for users with slow connections or large files + 'timeout' => $this->timeout ]); $this->removeCachedFile($target); } - /** {@inheritdoc} */ - public function rename($source, $target) { + public function rename(string $source, string $target): bool { $this->init(); $source = $this->cleanPath($source); $target = $this->cleanPath($target); @@ -549,8 +518,7 @@ class DAV extends Common { return false; } - /** {@inheritdoc} */ - public function copy($source, $target) { + public function copy(string $source, string $target): bool { $this->init(); $source = $this->cleanPath($source); $target = $this->cleanPath($target); @@ -578,62 +546,80 @@ class DAV extends Common { return false; } - /** {@inheritdoc} */ - public function stat($path) { - try { - $response = $this->propfind($path); - if (!$response) { - return false; - } - return [ - 'mtime' => isset($response['{DAV:}getlastmodified']) ? strtotime($response['{DAV:}getlastmodified']) : null, - 'size' => (int)($response['{DAV:}getcontentlength'] ?? 0), - ]; - } catch (\Exception $e) { - $this->convertException($e, $path); + 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); } - return []; } + 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); + } - /** {@inheritdoc} */ - public function getMimeType($path) { - $remoteMimetype = $this->getMimeTypeFromRemote($path); - if ($remoteMimetype === 'application/octet-stream') { - return \OC::$server->getMimeTypeDetector()->detectPath($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 { - return $remoteMimetype; + $mimeType = $this->mimeTypeDetector->detectPath($path); } - } - public function getMimeTypeFromRemote($path) { - try { - $response = $this->propfind($path); - if ($response === false) { - return false; - } - $responseType = []; - if (isset($response["{DAV:}resourcetype"])) { - /** @var ResourceType[] $response */ - $responseType = $response["{DAV:}resourcetype"]->getValue(); - } - $type = (count($responseType) > 0 and $responseType[0] == "{DAV:}collection") ? 'dir' : 'file'; - if ($type == 'dir') { - return 'httpd/unix-directory'; - } elseif (isset($response['{DAV:}getcontenttype'])) { - return $response['{DAV:}getcontenttype']; - } else { - return 'application/octet-stream'; - } - } catch (\Exception $e) { - return false; + 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, + ]; } - /** - * @param string $path - * @return string - */ - public function cleanPath($path) { + 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; } @@ -648,21 +634,17 @@ class DAV extends Common { * @param string $path to encode * @return string encoded path */ - protected function encodePath($path) { + protected function encodePath(string $path): string { // slashes need to stay return str_replace('%2F', '/', rawurlencode($path)); } /** - * @param string $method - * @param string $path - * @param string|resource|null $body - * @param int $expected * @return bool * @throws StorageInvalidException * @throws StorageNotAvailableException */ - protected function simpleResponse($method, $path, $body, $expected) { + 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); @@ -684,98 +666,55 @@ class DAV extends Common { /** * check if curl is installed */ - public static function checkDependencies() { + public static function checkDependencies(): bool { return true; } - /** {@inheritdoc} */ - public function isUpdatable($path) { + public function isUpdatable(string $path): bool { return (bool)($this->getPermissions($path) & Constants::PERMISSION_UPDATE); } - /** {@inheritdoc} */ - public function isCreatable($path) { + public function isCreatable(string $path): bool { return (bool)($this->getPermissions($path) & Constants::PERMISSION_CREATE); } - /** {@inheritdoc} */ - public function isSharable($path) { + public function isSharable(string $path): bool { return (bool)($this->getPermissions($path) & Constants::PERMISSION_SHARE); } - /** {@inheritdoc} */ - public function isDeletable($path) { + public function isDeletable(string $path): bool { return (bool)($this->getPermissions($path) & Constants::PERMISSION_DELETE); } - /** {@inheritdoc} */ - public function getPermissions($path) { - $this->init(); - $path = $this->cleanPath($path); - $response = $this->propfind($path); - if ($response === false) { - return 0; - } - if (isset($response['{http://owncloud.org/ns}permissions'])) { - return $this->parsePermissions($response['{http://owncloud.org/ns}permissions']); - } elseif ($this->is_dir($path)) { - return Constants::PERMISSION_ALL; - } elseif ($this->file_exists($path)) { - return Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE; - } else { - return 0; - } + public function getPermissions(string $path): int { + $stat = $this->getMetaData($path); + return $stat ? $stat['permissions'] : 0; } - /** {@inheritdoc} */ - public function getETag($path) { - $this->init(); - $path = $this->cleanPath($path); - $response = $this->propfind($path); - if ($response === false) { - return null; - } - if (isset($response['{DAV:}getetag'])) { - $etag = trim($response['{DAV:}getetag'], '"'); - if (strlen($etag) > 40) { - $etag = md5($etag); - } - return $etag; - } - return parent::getEtag($path); + public function getETag(string $path): string|false { + $meta = $this->getMetaData($path); + return $meta ? $meta['etag'] : false; } - /** - * @param string $permissionsString - * @return int - */ - protected function parsePermissions($permissionsString) { + protected function parsePermissions(string $permissionsString): int { $permissions = Constants::PERMISSION_READ; - if (strpos($permissionsString, 'R') !== false) { + if (str_contains($permissionsString, 'R')) { $permissions |= Constants::PERMISSION_SHARE; } - if (strpos($permissionsString, 'D') !== false) { + if (str_contains($permissionsString, 'D')) { $permissions |= Constants::PERMISSION_DELETE; } - if (strpos($permissionsString, 'W') !== false) { + if (str_contains($permissionsString, 'W')) { $permissions |= Constants::PERMISSION_UPDATE; } - if (strpos($permissionsString, 'CK') !== false) { + if (str_contains($permissionsString, 'CK')) { $permissions |= Constants::PERMISSION_CREATE; $permissions |= Constants::PERMISSION_UPDATE; } return $permissions; } - /** - * check if a file or folder has been updated since $time - * - * @param string $path - * @param int $time - * @throws \OCP\Files\StorageNotAvailableException - * @return bool - */ - public function hasUpdated($path, $time) { + public function hasUpdated(string $path, int $time): bool { $this->init(); $path = $this->cleanPath($path); try { @@ -836,13 +775,13 @@ class DAV extends Common { * @param string $path optional path from the operation * * @throws StorageInvalidException if the storage is invalid, for example - * when the authentication expired or is invalid + * when the authentication expired or is invalid * @throws StorageNotAvailableException if the storage is not available, - * which might be temporary + * which might be temporary * @throws ForbiddenException if the action is not allowed */ - protected function convertException(Exception $e, $path = '') { - \OC::$server->get(LoggerInterface::class)->debug($e->getMessage(), ['app' => 'files_external', 'exception' => $e]); + 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); @@ -872,4 +811,31 @@ class DAV extends Common { // 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 index d748b3410c3..a8288de48d0 100644 --- a/lib/private/Files/Storage/FailedStorage.php +++ b/lib/private/Files/Storage/FailedStorage.php @@ -1,27 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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; @@ -38,181 +20,172 @@ class FailedStorage extends Common { protected $e; /** - * @param array $params ['exception' => \Exception] + * @param array $parameters ['exception' => \Exception] */ - public function __construct($params) { - $this->e = $params['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() { + public function getId(): string { // we can't return anything sane here return 'failedstorage'; } - public function mkdir($path) { - throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); - } - - public function rmdir($path) { - throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); - } - - public function opendir($path) { + public function mkdir(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function is_dir($path) { + public function rmdir(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function is_file($path) { + public function opendir(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function stat($path) { + public function is_dir(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function filetype($path) { + public function is_file(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function filesize($path) { + public function stat(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function isCreatable($path) { + public function filetype(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function isReadable($path) { + public function filesize(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function isUpdatable($path) { + public function isCreatable(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function isDeletable($path) { + public function isReadable(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function isSharable($path) { + public function isUpdatable(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function getPermissions($path) { + public function isDeletable(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function file_exists($path) { + public function isSharable(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function filemtime($path) { + public function getPermissions(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function file_get_contents($path) { + public function file_exists(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function file_put_contents($path, $data) { + public function filemtime(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function unlink($path) { + public function file_get_contents(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function rename($source, $target) { + public function file_put_contents(string $path, mixed $data): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function copy($source, $target) { + public function unlink(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function fopen($path, $mode) { + public function rename(string $source, string $target): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function getMimeType($path) { + public function copy(string $source, string $target): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function hash($type, $path, $raw = false) { + public function fopen(string $path, string $mode): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function free_space($path) { + public function getMimeType(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function search($query) { + public function hash(string $type, string $path, bool $raw = false): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function touch($path, $mtime = null) { + public function free_space(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function getLocalFile($path) { + public function touch(string $path, ?int $mtime = null): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function getLocalFolder($path) { + public function getLocalFile(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function hasUpdated($path, $time) { + public function hasUpdated(string $path, int $time): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function getETag($path) { + public function getETag(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function getDirectDownload($path) { + public function getDirectDownload(string $path): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function verifyPath($path, $fileName) { - return true; + public function verifyPath(string $path, string $fileName): void { } - public function copyFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath, $preserveMtime = false) { + 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, $sourceInternalPath, $targetInternalPath) { + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function acquireLock($path, $type, ILockingProvider $provider) { + public function acquireLock(string $path, int $type, ILockingProvider $provider): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function releaseLock($path, $type, ILockingProvider $provider) { + public function releaseLock(string $path, int $type, ILockingProvider $provider): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function changeLock($path, $type, ILockingProvider $provider) { + public function changeLock(string $path, int $type, ILockingProvider $provider): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function getAvailability() { + public function getAvailability(): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function setAvailability($isAvailable) { + public function setAvailability(bool $isAvailable): never { throw new StorageNotAvailableException($this->e->getMessage(), $this->e->getCode(), $this->e); } - public function getCache($path = '', $storage = null) { + 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 index 5427bc425c2..91b8071ac30 100644 --- a/lib/private/Files/Storage/Home.php +++ b/lib/private/Files/Storage/Home.php @@ -1,31 +1,17 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Björn Schießle <bjoern@schiessle.org> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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 @@ -44,41 +30,32 @@ class Home extends Local implements \OCP\Files\IHomeStorage { /** * Construct a Home storage instance * - * @param array $arguments array with "user" containing the - * storage owner + * @param array $parameters array with "user" containing the + * storage owner */ - public function __construct($arguments) { - $this->user = $arguments['user']; + 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() { + public function getId(): string { return $this->id; } - /** - * @return \OC\Files\Cache\HomeCache - */ - public function getCache($path = '', $storage = null) { + 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->cache = new \OC\Files\Cache\HomeCache($storage, $this->getCacheDependencies()); } return $this->cache; } - /** - * get a propagator instance for the cache - * - * @param \OC\Files\Storage\Storage (optional) the storage to pass to the watcher - * @return \OC\Files\Cache\Propagator - */ - public function getPropagator($storage = null) { + public function getPropagator(?IStorage $storage = null): IPropagator { if (!$storage) { $storage = $this; } @@ -89,22 +66,11 @@ class Home extends Local implements \OCP\Files\IHomeStorage { } - /** - * Returns the owner of this home storage - * - * @return \OC\User\User owner of this home storage - */ - public function getUser() { + public function getUser(): IUser { return $this->user; } - /** - * get the owner of a path - * - * @param string $path The path to get the owner - * @return string uid or false - */ - public function getOwner($path) { + 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 index b021d40d335..260f9218a88 100644 --- a/lib/private/Files/Storage/Local.php +++ b/lib/private/Files/Storage/Local.php @@ -1,45 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author aler9 <46489434+aler9@users.noreply.github.com> - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Boris Rybalkin <ribalkin@gmail.com> - * @author Brice Maron <brice@bmaron.net> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author J0WI <J0WI@users.noreply.github.com> - * @author Jakob Sack <mail@jakobsack.de> - * @author Joas Schilling <coding@schilljs.com> - * @author Johannes Leuker <j.leuker@hosting.de> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Klaas Freitag <freitag@owncloud.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Martin Brugnara <martin@0x6d62.eu> - * @author Michael Gapczynski <GapczynskiM@gmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Sjors van der Pluijm <sjors@desjors.nl> - * @author Stefan Weil <sw@weilnetz.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Tigran Mkrtchyan <tigran.mkrtchyan@desy.de> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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; @@ -51,7 +15,10 @@ 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; /** @@ -72,11 +39,13 @@ class Local extends \OC\Files\Storage\Common { protected bool $unlinkOnTruncate; - public function __construct($arguments) { - if (!isset($arguments['datadir']) || !is_string($arguments['datadir'])) { + 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('//', '/', $arguments['datadir']); + $this->datadir = str_replace('//', '/', $parameters['datadir']); // some crazy code uses a local storage on root... if ($this->datadir === '/') { $this->realDataDir = $this->datadir; @@ -84,26 +53,33 @@ class Local extends \OC\Files\Storage\Common { $realPath = realpath($this->datadir) ?: $this->datadir; $this->realDataDir = rtrim($realPath, '/') . '/'; } - if (substr($this->datadir, -1) !== '/') { + if (!str_ends_with($this->datadir, '/')) { $this->datadir .= '/'; } $this->dataDirLength = strlen($this->realDataDir); - $this->config = \OC::$server->get(IConfig::class); - $this->mimeTypeDetector = \OC::$server->get(IMimeTypeDetector::class); + $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->getSystemValue('localstorage.unlink_on_truncate', false); + $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() { + public function getId(): string { return 'local::' . $this->datadir; } - public function mkdir($path) { + public function mkdir(string $path): bool { $sourcePath = $this->getSourcePath($path); $oldMask = umask($this->defUMask); $result = @mkdir($sourcePath, 0777, true); @@ -111,7 +87,7 @@ class Local extends \OC\Files\Storage\Common { return $result; } - public function rmdir($path) { + public function rmdir(string $path): bool { if (!$this->isDeletable($path)) { return false; } @@ -131,17 +107,18 @@ class Local extends \OC\Files\Storage\Common { * @var \SplFileInfo $file */ $file = $it->current(); - clearstatcache(true, $this->getSourcePath($file)); + clearstatcache(true, $file->getRealPath()); if (in_array($file->getBasename(), ['.', '..'])) { $it->next(); continue; - } elseif ($file->isDir()) { - rmdir($file->getPathname()); } 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) { @@ -149,38 +126,46 @@ class Local extends \OC\Files\Storage\Common { } } - public function opendir($path) { + public function opendir(string $path) { return opendir($this->getSourcePath($path)); } - public function is_dir($path) { - if (substr($path, -1) == '/') { + 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($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($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; } - /** - * @inheritdoc - */ - public function getMetaData($path) { + public function getMetaData(string $path): ?array { try { $stat = $this->stat($path); } catch (ForbiddenException $e) { @@ -229,7 +214,7 @@ class Local extends \OC\Files\Storage\Common { return $data; } - public function filetype($path) { + public function filetype(string $path): string|false { $filetype = filetype($this->getSourcePath($path)); if ($filetype == 'link') { $filetype = filetype(realpath($this->getSourcePath($path))); @@ -237,40 +222,58 @@ class Local extends \OC\Files\Storage\Common { return $filetype; } - public function filesize($path) { + 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($path) { + public function isReadable(string $path): bool { return is_readable($this->getSourcePath($path)); } - public function isUpdatable($path) { + public function isUpdatable(string $path): bool { return is_writable($this->getSourcePath($path)); } - public function file_exists($path) { - return file_exists($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($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($path, $mtime = null) { + 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) and !$this->isUpdatable($path)) { + if ($this->file_exists($path) && !$this->isUpdatable($path)) { return false; } $oldMask = umask($this->defUMask); @@ -287,11 +290,11 @@ class Local extends \OC\Files\Storage\Common { return $result; } - public function file_get_contents($path) { + public function file_get_contents(string $path): string|false { return file_get_contents($this->getSourcePath($path)); } - public function file_put_contents($path, $data) { + public function file_put_contents(string $path, mixed $data): int|float|false { $oldMask = umask($this->defUMask); if ($this->unlinkOnTruncate) { $this->unlink($path); @@ -301,7 +304,7 @@ class Local extends \OC\Files\Storage\Common { return $result; } - public function unlink($path) { + public function unlink(string $path): bool { if ($this->is_dir($path)) { return $this->rmdir($path); } elseif ($this->is_file($path)) { @@ -311,7 +314,7 @@ class Local extends \OC\Files\Storage\Common { } } - private function checkTreeForForbiddenItems(string $path) { + private function checkTreeForForbiddenItems(string $path): void { $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path)); foreach ($iterator as $file) { /** @var \SplFileInfo $file */ @@ -321,50 +324,50 @@ class Local extends \OC\Files\Storage\Common { } } - public function rename($source, $target) { + public function rename(string $source, string $target): bool { $srcParent = dirname($source); $dstParent = dirname($target); if (!$this->isUpdatable($srcParent)) { - \OC::$server->get(LoggerInterface::class)->error('unable to rename, source directory is not writable : ' . $srcParent, ['app' => 'core']); + Server::get(LoggerInterface::class)->error('unable to rename, source directory is not writable : ' . $srcParent, ['app' => 'core']); return false; } if (!$this->isUpdatable($dstParent)) { - \OC::$server->get(LoggerInterface::class)->error('unable to rename, destination directory is not writable : ' . $dstParent, ['app' => 'core']); + Server::get(LoggerInterface::class)->error('unable to rename, destination directory is not writable : ' . $dstParent, ['app' => 'core']); return false; } if (!$this->file_exists($source)) { - \OC::$server->get(LoggerInterface::class)->error('unable to rename, file does not exists : ' . $source, ['app' => 'core']); + Server::get(LoggerInterface::class)->error('unable to rename, file does not exists : ' . $source, ['app' => 'core']); return false; } - if ($this->is_dir($target)) { - $this->rmdir($target); - } elseif ($this->is_file($target)) { - $this->unlink($target); + 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)) { - // we can't move folders across devices, use copy instead - $stat1 = stat(dirname($this->getSourcePath($source))); - $stat2 = stat(dirname($this->getSourcePath($target))); - if ($stat1['dev'] !== $stat2['dev']) { - $result = $this->copy($source, $target); - if ($result) { - $result &= $this->rmdir($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 $result; } - - $this->checkTreeForForbiddenItems($this->getSourcePath($source)); + return true; } - return rename($this->getSourcePath($source), $this->getSourcePath($target)); + return $this->copy($source, $target) && $this->unlink($source); } - public function copy($source, $target) { + public function copy(string $source, string $target): bool { if ($this->is_dir($source)) { return parent::copy($source, $target); } else { @@ -374,11 +377,16 @@ class Local extends \OC\Files\Storage\Common { } $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($path, $mode) { + public function fopen(string $path, string $mode) { $sourcePath = $this->getSourcePath($path); if (!file_exists($sourcePath) && $mode === 'r') { return false; @@ -392,11 +400,11 @@ class Local extends \OC\Files\Storage\Common { return $result; } - public function hash($type, $path, $raw = false) { + public function hash(string $type, string $path, bool $raw = false): string|false { return hash_file($type, $this->getSourcePath($path), $raw); } - public function free_space($path) { + 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 @@ -405,31 +413,22 @@ class Local extends \OC\Files\Storage\Common { // disk_free_space doesn't work on files $sourcePath = dirname($sourcePath); } - $space = function_exists('disk_free_space') ? disk_free_space($sourcePath) : false; + $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 (int)$space; + return Util::numericToNumber($space); } - public function search($query) { + public function search(string $query): array { return $this->searchInDir($query); } - public function getLocalFile($path) { + public function getLocalFile(string $path): string|false { return $this->getSourcePath($path); } - public function getLocalFolder($path) { - return $this->getSourcePath($path); - } - - /** - * @param string $query - * @param string $dir - * @return array - */ - protected function searchInDir($query, $dir = '') { + protected function searchInDir(string $query, string $dir = ''): array { $files = []; $physicalDir = $this->getSourcePath($dir); foreach (scandir($physicalDir) as $item) { @@ -448,14 +447,7 @@ class Local extends \OC\Files\Storage\Common { return $files; } - /** - * check if a file or folder has been updated since $time - * - * @param string $path - * @param int $time - * @return bool - */ - public function hasUpdated($path, $time) { + public function hasUpdated(string $path, int $time): bool { if ($this->file_exists($path)) { return $this->filemtime($path) > $time; } else { @@ -466,18 +458,16 @@ class Local extends \OC\Files\Storage\Common { /** * Get the source path (on disk) of a given path * - * @param string $path - * @return string * @throws ForbiddenException */ - public function getSourcePath($path) { + 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->getSystemValue('localstorage.allowsymlinks', false); + $allowSymlinks = $this->config->getSystemValueBool('localstorage.allowsymlinks', false); if ($allowSymlinks || $currentPath === '') { return $fullPath; } @@ -485,6 +475,7 @@ class Local extends \OC\Files\Storage\Common { $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; } @@ -497,28 +488,19 @@ class Local extends \OC\Files\Storage\Common { return $fullPath; } - \OC::$server->get(LoggerInterface::class)->error("Following symlinks is not allowed ('$fullPath' -> '$realPath' not inside '{$this->realDataDir}')", ['app' => 'core']); + 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); } - /** - * {@inheritdoc} - */ - public function isLocal() { + public function isLocal(): bool { return true; } - /** - * get the ETag for a file or folder - * - * @param string $path - * @return string - */ - public function getETag($path) { + public function getETag(string $path): string|false { return $this->calculateEtag($path, $this->stat($path)); } - private function calculateEtag(string $path, array $stat): string { + 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 { @@ -544,30 +526,28 @@ class Local extends \OC\Files\Storage\Common { } } - private function canDoCrossStorageMove(IStorage $sourceStorage) { + 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); } - /** - * @param IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @param bool $preserveMtime - * @return bool - */ - public function copyFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath, $preserveMtime = false) { + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath, bool $preserveMtime = false): bool { if ($this->canDoCrossStorageMove($sourceStorage)) { - if ($sourceStorage->instanceOfStorage(Jail::class)) { + // 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 @@ -579,19 +559,15 @@ class Local extends \OC\Files\Storage\Common { } } - /** - * @param IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @return bool - */ - public function moveFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { if ($this->canDoCrossStorageMove($sourceStorage)) { - if ($sourceStorage->instanceOfStorage(Jail::class)) { + // 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 @@ -603,7 +579,8 @@ class Local extends \OC\Files\Storage\Common { } } - public function writeStream(string $path, $stream, int $size = null): int { + 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); diff --git a/lib/private/Files/Storage/LocalRootStorage.php b/lib/private/Files/Storage/LocalRootStorage.php index 71584afef08..2e0645e092a 100644 --- a/lib/private/Files/Storage/LocalRootStorage.php +++ b/lib/private/Files/Storage/LocalRootStorage.php @@ -3,38 +3,20 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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($path = '', $storage = null) { + public function getScanner(string $path = '', ?IStorage $storage = null): IScanner { if (!$storage) { $storage = $this; } - if (!isset($storage->scanner)) { - $storage->scanner = new LocalRootScanner($storage); - } - return $storage->scanner; + return $storage->scanner ?? ($storage->scanner = new LocalRootScanner($storage)); } } diff --git a/lib/private/Files/Storage/LocalTempFileTrait.php b/lib/private/Files/Storage/LocalTempFileTrait.php index 2ac0a74b692..fffc3e789f3 100644 --- a/lib/private/Files/Storage/LocalTempFileTrait.php +++ b/lib/private/Files/Storage/LocalTempFileTrait.php @@ -1,28 +1,14 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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. @@ -35,32 +21,21 @@ namespace OC\Files\Storage; * in classes which extend it, e.g. $this->stat() . */ trait LocalTempFileTrait { - /** @var string[] */ - protected $cachedFiles = []; + /** @var array<string,string|false> */ + protected array $cachedFiles = []; - /** - * @param string $path - * @return string - */ - protected function getCachedFile($path) { + protected function getCachedFile(string $path): string|false { if (!isset($this->cachedFiles[$path])) { $this->cachedFiles[$path] = $this->toTmpFile($path); } return $this->cachedFiles[$path]; } - /** - * @param string $path - */ - protected function removeCachedFile($path) { + protected function removeCachedFile(string $path): void { unset($this->cachedFiles[$path]); } - /** - * @param string $path - * @return string - */ - protected function toTmpFile($path) { //no longer in the storage api, still useful here + 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; @@ -72,7 +47,7 @@ trait LocalTempFileTrait { } $tmpFile = \OC::$server->getTempManager()->getTemporaryFile($extension); $target = fopen($tmpFile, 'w'); - \OC_Helper::streamCopy($source, $target); + 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 index ff05eecb134..2f6167ef85e 100644 --- a/lib/private/Files/Storage/PolyFill/CopyDirectory.php +++ b/lib/private/Files/Storage/PolyFill/CopyDirectory.php @@ -1,70 +1,41 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Martin Mattel <martin.mattel@diemattels.at> - * @author Robin Appelman <robin@icewind.nl> - * @author Stefan Weil <sw@weilnetz.de> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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 - * - * @param string $path - * @return bool */ - abstract public function is_dir($path); + abstract public function is_dir(string $path): bool; /** * Check if a file or folder exists - * - * @param string $path - * @return bool */ - abstract public function file_exists($path); + abstract public function file_exists(string $path): bool; /** * Delete a file or folder - * - * @param string $path - * @return bool */ - abstract public function unlink($path); + abstract public function unlink(string $path): bool; /** * Open a directory handle for a folder * - * @param string $path - * @return resource | bool + * @return resource|false */ - abstract public function opendir($path); + abstract public function opendir(string $path); /** * Create a new folder - * - * @param string $path - * @return bool */ - abstract public function mkdir($path); + abstract public function mkdir(string $path): bool; - public function copy($source, $target) { + public function copy(string $source, string $target): bool { if ($this->is_dir($source)) { if ($this->file_exists($target)) { $this->unlink($target); @@ -78,15 +49,11 @@ trait CopyDirectory { /** * For adapters that don't support copying folders natively - * - * @param $source - * @param $target - * @return bool */ - protected function copyRecursive($source, $target) { + protected function copyRecursive(string $source, string $target): bool { $dh = $this->opendir($source); $result = true; - while ($file = readdir($dh)) { + while (($file = readdir($dh)) !== false) { if (!\OC\Files\Filesystem::isIgnoredDir($file)) { if ($this->is_dir($source . '/' . $file)) { $this->mkdir($target . '/' . $file); diff --git a/lib/private/Files/Storage/Storage.php b/lib/private/Files/Storage/Storage.php index 0a2511de164..aa17c12b309 100644 --- a/lib/private/Files/Storage/Storage.php +++ b/lib/private/Files/Storage/Storage.php @@ -1,130 +1,44 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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\Lock\ILockingProvider; +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 \OCP\Files\Storage { - /** - * get a cache instance for the storage - * - * @param string $path - * @param \OC\Files\Storage\Storage|null (optional) the storage to pass to the cache - * @return \OC\Files\Cache\Cache - */ - public function getCache($path = '', $storage = null); - - /** - * get a scanner instance for the storage - * - * @param string $path - * @param \OC\Files\Storage\Storage (optional) the storage to pass to the scanner - * @return \OC\Files\Cache\Scanner - */ - public function getScanner($path = '', $storage = null); - - - /** - * get the user id of the owner of a file or folder - * - * @param string $path - * @return string - */ - public function getOwner($path); - - /** - * get a watcher instance for the cache - * - * @param string $path - * @param \OC\Files\Storage\Storage (optional) the storage to pass to the watcher - * @return \OC\Files\Cache\Watcher - */ - public function getWatcher($path = '', $storage = null); +interface Storage extends IStorage, ILockingStorage { + public function getCache(string $path = '', ?IStorage $storage = null): ICache; - /** - * get a propagator instance for the cache - * - * @param \OC\Files\Storage\Storage (optional) the storage to pass to the watcher - * @return \OC\Files\Cache\Propagator - */ - public function getPropagator($storage = null); + public function getScanner(string $path = '', ?IStorage $storage = null): IScanner; - /** - * get a updater instance for the cache - * - * @param \OC\Files\Storage\Storage (optional) the storage to pass to the watcher - * @return \OC\Files\Cache\Updater - */ - public function getUpdater($storage = null); + public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher; - /** - * @return \OC\Files\Cache\Storage - */ - public function getStorageCache(); + public function getPropagator(?IStorage $storage = null): IPropagator; - /** - * @param string $path - * @return array|null - */ - public function getMetaData($path); - - /** - * @param string $path The path of the file to acquire the lock for - * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE - * @param \OCP\Lock\ILockingProvider $provider - * @throws \OCP\Lock\LockedException - */ - public function acquireLock($path, $type, ILockingProvider $provider); + public function getUpdater(?IStorage $storage = null): IUpdater; - /** - * @param string $path The path of the file to release the lock for - * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE - * @param \OCP\Lock\ILockingProvider $provider - * @throws \OCP\Lock\LockedException - */ - public function releaseLock($path, $type, ILockingProvider $provider); + public function getStorageCache(): \OC\Files\Cache\Storage; - /** - * @param string $path The path of the file to change the lock for - * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE - * @param \OCP\Lock\ILockingProvider $provider - * @throws \OCP\Lock\LockedException - */ - public function changeLock($path, $type, ILockingProvider $provider); + public function getMetaData(string $path): ?array; /** * Get the contents of a directory with metadata * - * @param string $directory - * @return \Traversable an iterator, containing file metadata - * * The metadata array will contain the following fields * * - name @@ -135,5 +49,5 @@ interface Storage extends \OCP\Files\Storage { * - storage_mtime * - permissions */ - public function getDirectoryContent($directory): \Traversable; + public function getDirectoryContent(string $directory): \Traversable; } diff --git a/lib/private/Files/Storage/StorageFactory.php b/lib/private/Files/Storage/StorageFactory.php index cab739c4a81..603df7fe007 100644 --- a/lib/private/Files/Storage/StorageFactory.php +++ b/lib/private/Files/Storage/StorageFactory.php @@ -1,31 +1,17 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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 { /** @@ -33,19 +19,7 @@ class StorageFactory implements IStorageFactory { */ private $storageWrappers = []; - /** - * allow modifier storage behaviour by adding wrappers around storages - * - * $callback should be a function of type (string $mountPoint, Storage $storage) => Storage - * - * @param string $wrapperName name of the wrapper - * @param callable $callback callback - * @param int $priority wrappers with the lower priority are applied last (meaning they get called first) - * @param \OCP\Files\Mount\IMountPoint[] $existingMounts existing mount points to apply the wrapper to - * @return bool true if the wrapper was added, false if there was already a wrapper with this - * name registered - */ - public function addStorageWrapper($wrapperName, $callback, $priority = 50, $existingMounts = []) { + public function addStorageWrapper(string $wrapperName, callable $callback, int $priority = 50, array $existingMounts = []): bool { if (isset($this->storageWrappers[$wrapperName])) { return false; } @@ -63,31 +37,23 @@ class StorageFactory implements IStorageFactory { * Remove a storage wrapper by name. * Note: internal method only to be used for cleanup * - * @param string $wrapperName name of the wrapper * @internal */ - public function removeStorageWrapper($wrapperName) { + public function removeStorageWrapper(string $wrapperName): void { unset($this->storageWrappers[$wrapperName]); } /** * Create an instance of a storage and apply the registered storage wrappers - * - * @param \OCP\Files\Mount\IMountPoint $mountPoint - * @param string $class - * @param array $arguments - * @return \OCP\Files\Storage */ - public function getInstance(IMountPoint $mountPoint, $class, $arguments) { + 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)); } - /** - * @param \OCP\Files\Mount\IMountPoint $mountPoint - * @param \OCP\Files\Storage $storage - * @return \OCP\Files\Storage - */ - public function wrap(IMountPoint $mountPoint, $storage) { + public function wrap(IMountPoint $mountPoint, IStorage $storage): IStorage { $wrappers = array_values($this->storageWrappers); usort($wrappers, function ($a, $b) { return $b['priority'] - $a['priority']; @@ -98,7 +64,7 @@ class StorageFactory implements IStorageFactory { }, $wrappers); foreach ($wrappers as $wrapper) { $storage = $wrapper($mountPoint->getMountPoint(), $storage, $mountPoint); - if (!($storage instanceof \OCP\Files\Storage)) { + if (!($storage instanceof IStorage)) { throw new \Exception('Invalid result from storage wrapper'); } } diff --git a/lib/private/Files/Storage/Temporary.php b/lib/private/Files/Storage/Temporary.php index 393a37f834a..ecf8a1315a9 100644 --- a/lib/private/Files/Storage/Temporary.php +++ b/lib/private/Files/Storage/Temporary.php @@ -1,40 +1,26 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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($arguments = null) { - parent::__construct(['datadir' => \OC::$server->getTempManager()->getTemporaryFolder()]); + public function __construct(array $parameters = []) { + parent::__construct(['datadir' => Server::get(ITempManager::class)->getTemporaryFolder()]); } - public function cleanUp() { - \OC_Helper::rmdirr($this->datadir); + public function cleanUp(): void { + Files::rmdirr($this->datadir); } public function __destruct() { @@ -42,7 +28,7 @@ class Temporary extends Local { $this->cleanUp(); } - public function getDataDir() { + 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 index a4a6fa0bd16..32c51a1b25e 100644 --- a/lib/private/Files/Storage/Wrapper/Availability.php +++ b/lib/private/Files/Storage/Wrapper/Availability.php @@ -1,28 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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; @@ -42,12 +23,12 @@ class Availability extends Wrapper { /** @var IConfig */ protected $config; - public function __construct($parameters) { - $this->config = $parameters['config'] ?? \OC::$server->getConfig(); + public function __construct(array $parameters) { + $this->config = $parameters['config'] ?? \OCP\Server::get(IConfig::class); parent::__construct($parameters); } - public static function shouldRecheck($availability) { + public static function shouldRecheck($availability): bool { if (!$availability['available']) { // trigger a recheck if TTL reached if ((time() - $availability['last_checked']) > self::RECHECK_TTL_SEC) { @@ -59,10 +40,8 @@ class Availability extends Wrapper { /** * Only called if availability === false - * - * @return bool */ - private function updateAvailability() { + private function updateAvailability(): bool { // reset availability to false so that multiple requests don't recheck concurrently $this->setAvailability(false); try { @@ -74,10 +53,7 @@ class Availability extends Wrapper { return $result; } - /** - * @return bool - */ - private function isAvailable() { + private function isAvailable(): bool { $availability = $this->getAvailability(); if (self::shouldRecheck($availability)) { return $this->updateAvailability(); @@ -88,297 +64,137 @@ class Availability extends Wrapper { /** * @throws StorageNotAvailableException */ - private function checkAvailability() { + private function checkAvailability(): void { if (!$this->isAvailable()) { throw new StorageNotAvailableException(); } } - /** {@inheritdoc} */ - public function mkdir($path) { + /** + * Handles availability checks and delegates method calls dynamically + */ + private function handleAvailability(string $method, mixed ...$args): mixed { $this->checkAvailability(); try { - return parent::mkdir($path); + return call_user_func_array([parent::class, $method], $args); } catch (StorageNotAvailableException $e) { $this->setUnavailable($e); + return false; } } - /** {@inheritdoc} */ - public function rmdir($path) { - $this->checkAvailability(); - try { - return parent::rmdir($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function mkdir(string $path): bool { + return $this->handleAvailability('mkdir', $path); } - /** {@inheritdoc} */ - public function opendir($path) { - $this->checkAvailability(); - try { - return parent::opendir($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function rmdir(string $path): bool { + return $this->handleAvailability('rmdir', $path); } - /** {@inheritdoc} */ - public function is_dir($path) { - $this->checkAvailability(); - try { - return parent::is_dir($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function opendir(string $path) { + return $this->handleAvailability('opendir', $path); } - /** {@inheritdoc} */ - public function is_file($path) { - $this->checkAvailability(); - try { - return parent::is_file($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function is_dir(string $path): bool { + return $this->handleAvailability('is_dir', $path); } - /** {@inheritdoc} */ - public function stat($path) { - $this->checkAvailability(); - try { - return parent::stat($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function is_file(string $path): bool { + return $this->handleAvailability('is_file', $path); } - /** {@inheritdoc} */ - public function filetype($path) { - $this->checkAvailability(); - try { - return parent::filetype($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function stat(string $path): array|false { + return $this->handleAvailability('stat', $path); } - /** {@inheritdoc} */ - public function filesize($path) { - $this->checkAvailability(); - try { - return parent::filesize($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function filetype(string $path): string|false { + return $this->handleAvailability('filetype', $path); } - /** {@inheritdoc} */ - public function isCreatable($path) { - $this->checkAvailability(); - try { - return parent::isCreatable($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function filesize(string $path): int|float|false { + return $this->handleAvailability('filesize', $path); } - /** {@inheritdoc} */ - public function isReadable($path) { - $this->checkAvailability(); - try { - return parent::isReadable($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function isCreatable(string $path): bool { + return $this->handleAvailability('isCreatable', $path); } - /** {@inheritdoc} */ - public function isUpdatable($path) { - $this->checkAvailability(); - try { - return parent::isUpdatable($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function isReadable(string $path): bool { + return $this->handleAvailability('isReadable', $path); } - /** {@inheritdoc} */ - public function isDeletable($path) { - $this->checkAvailability(); - try { - return parent::isDeletable($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function isUpdatable(string $path): bool { + return $this->handleAvailability('isUpdatable', $path); } - /** {@inheritdoc} */ - public function isSharable($path) { - $this->checkAvailability(); - try { - return parent::isSharable($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function isDeletable(string $path): bool { + return $this->handleAvailability('isDeletable', $path); } - /** {@inheritdoc} */ - public function getPermissions($path) { - $this->checkAvailability(); - try { - return parent::getPermissions($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function isSharable(string $path): bool { + return $this->handleAvailability('isSharable', $path); } - /** {@inheritdoc} */ - public function file_exists($path) { - if ($path === '') { - return true; - } - $this->checkAvailability(); - try { - return parent::file_exists($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function getPermissions(string $path): int { + return $this->handleAvailability('getPermissions', $path); } - /** {@inheritdoc} */ - public function filemtime($path) { - $this->checkAvailability(); - try { - return parent::filemtime($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); + public function file_exists(string $path): bool { + if ($path === '') { + return true; } + return $this->handleAvailability('file_exists', $path); } - /** {@inheritdoc} */ - public function file_get_contents($path) { - $this->checkAvailability(); - try { - return parent::file_get_contents($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function filemtime(string $path): int|false { + return $this->handleAvailability('filemtime', $path); } - /** {@inheritdoc} */ - public function file_put_contents($path, $data) { - $this->checkAvailability(); - try { - return parent::file_put_contents($path, $data); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function file_get_contents(string $path): string|false { + return $this->handleAvailability('file_get_contents', $path); } - /** {@inheritdoc} */ - public function unlink($path) { - $this->checkAvailability(); - try { - return parent::unlink($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function file_put_contents(string $path, mixed $data): int|float|false { + return $this->handleAvailability('file_put_contents', $path, $data); } - /** {@inheritdoc} */ - public function rename($source, $target) { - $this->checkAvailability(); - try { - return parent::rename($source, $target); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function unlink(string $path): bool { + return $this->handleAvailability('unlink', $path); } - /** {@inheritdoc} */ - public function copy($source, $target) { - $this->checkAvailability(); - try { - return parent::copy($source, $target); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function rename(string $source, string $target): bool { + return $this->handleAvailability('rename', $source, $target); } - /** {@inheritdoc} */ - public function fopen($path, $mode) { - $this->checkAvailability(); - try { - return parent::fopen($path, $mode); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function copy(string $source, string $target): bool { + return $this->handleAvailability('copy', $source, $target); } - /** {@inheritdoc} */ - public function getMimeType($path) { - $this->checkAvailability(); - try { - return parent::getMimeType($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function fopen(string $path, string $mode) { + return $this->handleAvailability('fopen', $path, $mode); } - /** {@inheritdoc} */ - public function hash($type, $path, $raw = false) { - $this->checkAvailability(); - try { - return parent::hash($type, $path, $raw); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function getMimeType(string $path): string|false { + return $this->handleAvailability('getMimeType', $path); } - /** {@inheritdoc} */ - public function free_space($path) { - $this->checkAvailability(); - try { - return parent::free_space($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function hash(string $type, string $path, bool $raw = false): string|false { + return $this->handleAvailability('hash', $type, $path, $raw); } - /** {@inheritdoc} */ - public function search($query) { - $this->checkAvailability(); - try { - return parent::search($query); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function free_space(string $path): int|float|false { + return $this->handleAvailability('free_space', $path); } - /** {@inheritdoc} */ - public function touch($path, $mtime = null) { - $this->checkAvailability(); - try { - return parent::touch($path, $mtime); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function touch(string $path, ?int $mtime = null): bool { + return $this->handleAvailability('touch', $path, $mtime); } - /** {@inheritdoc} */ - public function getLocalFile($path) { - $this->checkAvailability(); - try { - return parent::getLocalFile($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function getLocalFile(string $path): string|false { + return $this->handleAvailability('getLocalFile', $path); } - /** {@inheritdoc} */ - public function hasUpdated($path, $time) { + public function hasUpdated(string $path, int $time): bool { if (!$this->isAvailable()) { return false; } @@ -391,66 +207,45 @@ class Availability extends Wrapper { } } - /** {@inheritdoc} */ - public function getOwner($path) { + public function getOwner(string $path): string|false { try { return parent::getOwner($path); } catch (StorageNotAvailableException $e) { $this->setUnavailable($e); + return false; } } - /** {@inheritdoc} */ - public function getETag($path) { - $this->checkAvailability(); - try { - return parent::getETag($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function getETag(string $path): string|false { + return $this->handleAvailability('getETag', $path); } - /** {@inheritdoc} */ - public function getDirectDownload($path) { - $this->checkAvailability(); - try { - return parent::getDirectDownload($path); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function getDirectDownload(string $path): array|false { + return $this->handleAvailability('getDirectDownload', $path); } - /** {@inheritdoc} */ - public function copyFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { - $this->checkAvailability(); - try { - return parent::copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + return $this->handleAvailability('copyFromStorage', $sourceStorage, $sourceInternalPath, $targetInternalPath); } - /** {@inheritdoc} */ - public function moveFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { - $this->checkAvailability(); - try { - return parent::moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); - } catch (StorageNotAvailableException $e) { - $this->setUnavailable($e); - } + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + return $this->handleAvailability('moveFromStorage', $sourceStorage, $sourceInternalPath, $targetInternalPath); } - /** {@inheritdoc} */ - public function getMetaData($path) { + 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 { @@ -470,12 +265,13 @@ class Availability extends Wrapper { - public function getDirectoryContent($directory): \Traversable { + 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 index ed680f5045d..92e20cfb3df 100644 --- a/lib/private/Files/Storage/Wrapper/Encoding.php +++ b/lib/private/Files/Storage/Wrapper/Encoding.php @@ -1,35 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author J0WI <J0WI@users.noreply.github.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Tigran Mkrtchyan <tigran.mkrtchyan@desy.de> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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\Cache\CappedMemoryCache; use OC\Files\Filesystem; +use OCP\Cache\CappedMemoryCache; +use OCP\Files\Cache\IScanner; use OCP\Files\Storage\IStorage; use OCP\ICache; @@ -48,19 +28,15 @@ class Encoding extends Wrapper { /** * @param array $parameters */ - public function __construct($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 - * - * @param string $str string - * - * @return bool true if the string is all ASCII, false otherwise */ - private function isAscii($str) { + private function isAscii(string $str): bool { return !preg_match('/[\\x80-\\xff]+/', $str); } @@ -69,11 +45,9 @@ class Encoding extends Wrapper { * each form for each path section and returns the correct form. * If no existing path found, returns the path as it was given. * - * @param string $fullPath path to check - * * @return string original or converted path */ - private function findPathToUse($fullPath) { + private function findPathToUse(string $fullPath): string { $cachedPath = $this->namesCache[$fullPath]; if ($cachedPath !== null) { return $cachedPath; @@ -97,12 +71,11 @@ class Encoding extends Wrapper { * 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 $basePath base path to check * @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($basePath, $lastSection) { + 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; @@ -126,13 +99,7 @@ class Encoding extends Wrapper { return null; } - /** - * see https://www.php.net/manual/en/function.mkdir.php - * - * @param string $path - * @return bool - */ - public function mkdir($path) { + 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) { @@ -141,13 +108,7 @@ class Encoding extends Wrapper { return $result; } - /** - * see https://www.php.net/manual/en/function.rmdir.php - * - * @param string $path - * @return bool - */ - public function rmdir($path) { + public function rmdir(string $path): bool { $result = $this->storage->rmdir($this->findPathToUse($path)); if ($result) { unset($this->namesCache[$path]); @@ -155,178 +116,72 @@ class Encoding extends Wrapper { return $result; } - /** - * see https://www.php.net/manual/en/function.opendir.php - * - * @param string $path - * @return resource|bool - */ - public function opendir($path) { + public function opendir(string $path) { $handle = $this->storage->opendir($this->findPathToUse($path)); return EncodingDirectoryWrapper::wrap($handle); } - /** - * see https://www.php.net/manual/en/function.is_dir.php - * - * @param string $path - * @return bool - */ - public function is_dir($path) { + public function is_dir(string $path): bool { return $this->storage->is_dir($this->findPathToUse($path)); } - /** - * see https://www.php.net/manual/en/function.is_file.php - * - * @param string $path - * @return bool - */ - public function is_file($path) { + public function is_file(string $path): bool { return $this->storage->is_file($this->findPathToUse($path)); } - /** - * see https://www.php.net/manual/en/function.stat.php - * only the following keys are required in the result: size and mtime - * - * @param string $path - * @return array|bool - */ - public function stat($path) { + public function stat(string $path): array|false { return $this->storage->stat($this->findPathToUse($path)); } - /** - * see https://www.php.net/manual/en/function.filetype.php - * - * @param string $path - * @return string|bool - */ - public function filetype($path) { + public function filetype(string $path): string|false { return $this->storage->filetype($this->findPathToUse($path)); } - /** - * see https://www.php.net/manual/en/function.filesize.php - * The result for filesize when called on a folder is required to be 0 - * - * @param string $path - * @return int|bool - */ - public function filesize($path) { + public function filesize(string $path): int|float|false { return $this->storage->filesize($this->findPathToUse($path)); } - /** - * check if a file can be created in $path - * - * @param string $path - * @return bool - */ - public function isCreatable($path) { + public function isCreatable(string $path): bool { return $this->storage->isCreatable($this->findPathToUse($path)); } - /** - * check if a file can be read - * - * @param string $path - * @return bool - */ - public function isReadable($path) { + public function isReadable(string $path): bool { return $this->storage->isReadable($this->findPathToUse($path)); } - /** - * check if a file can be written to - * - * @param string $path - * @return bool - */ - public function isUpdatable($path) { + public function isUpdatable(string $path): bool { return $this->storage->isUpdatable($this->findPathToUse($path)); } - /** - * check if a file can be deleted - * - * @param string $path - * @return bool - */ - public function isDeletable($path) { + public function isDeletable(string $path): bool { return $this->storage->isDeletable($this->findPathToUse($path)); } - /** - * check if a file can be shared - * - * @param string $path - * @return bool - */ - public function isSharable($path) { + public function isSharable(string $path): bool { return $this->storage->isSharable($this->findPathToUse($path)); } - /** - * get the full permissions of a path. - * Should return a combination of the PERMISSION_ constants defined in lib/public/constants.php - * - * @param string $path - * @return int - */ - public function getPermissions($path) { + public function getPermissions(string $path): int { return $this->storage->getPermissions($this->findPathToUse($path)); } - /** - * see https://www.php.net/manual/en/function.file_exists.php - * - * @param string $path - * @return bool - */ - public function file_exists($path) { + public function file_exists(string $path): bool { return $this->storage->file_exists($this->findPathToUse($path)); } - /** - * see https://www.php.net/manual/en/function.filemtime.php - * - * @param string $path - * @return int|bool - */ - public function filemtime($path) { + public function filemtime(string $path): int|false { return $this->storage->filemtime($this->findPathToUse($path)); } - /** - * see https://www.php.net/manual/en/function.file_get_contents.php - * - * @param string $path - * @return string|bool - */ - public function file_get_contents($path) { + public function file_get_contents(string $path): string|false { return $this->storage->file_get_contents($this->findPathToUse($path)); } - /** - * see https://www.php.net/manual/en/function.file_put_contents.php - * - * @param string $path - * @param mixed $data - * @return int|false - */ - public function file_put_contents($path, $data) { + public function file_put_contents(string $path, mixed $data): int|float|false { return $this->storage->file_put_contents($this->findPathToUse($path), $data); } - /** - * see https://www.php.net/manual/en/function.unlink.php - * - * @param string $path - * @return bool - */ - public function unlink($path) { + public function unlink(string $path): bool { $result = $this->storage->unlink($this->findPathToUse($path)); if ($result) { unset($this->namesCache[$path]); @@ -334,37 +189,16 @@ class Encoding extends Wrapper { return $result; } - /** - * see https://www.php.net/manual/en/function.rename.php - * - * @param string $source - * @param string $target - * @return bool - */ - public function rename($source, $target) { + public function rename(string $source, string $target): bool { // second name always NFC return $this->storage->rename($this->findPathToUse($source), $this->findPathToUse($target)); } - /** - * see https://www.php.net/manual/en/function.copy.php - * - * @param string $source - * @param string $target - * @return bool - */ - public function copy($source, $target) { + public function copy(string $source, string $target): bool { return $this->storage->copy($this->findPathToUse($source), $this->findPathToUse($target)); } - /** - * see https://www.php.net/manual/en/function.fopen.php - * - * @param string $path - * @param string $mode - * @return resource|bool - */ - public function fopen($path, $mode) { + 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]); @@ -372,131 +206,49 @@ class Encoding extends Wrapper { return $result; } - /** - * get the mimetype for a file or folder - * The mimetype for a folder is required to be "httpd/unix-directory" - * - * @param string $path - * @return string|bool - */ - public function getMimeType($path) { + public function getMimeType(string $path): string|false { return $this->storage->getMimeType($this->findPathToUse($path)); } - /** - * see https://www.php.net/manual/en/function.hash.php - * - * @param string $type - * @param string $path - * @param bool $raw - * @return string|bool - */ - public function hash($type, $path, $raw = false) { + public function hash(string $type, string $path, bool $raw = false): string|false { return $this->storage->hash($type, $this->findPathToUse($path), $raw); } - /** - * see https://www.php.net/manual/en/function.free_space.php - * - * @param string $path - * @return int|bool - */ - public function free_space($path) { + public function free_space(string $path): int|float|false { return $this->storage->free_space($this->findPathToUse($path)); } - /** - * search for occurrences of $query in file names - * - * @param string $query - * @return array|bool - */ - public function search($query) { - return $this->storage->search($query); - } - - /** - * see https://www.php.net/manual/en/function.touch.php - * If the backend does not support the operation, false should be returned - * - * @param string $path - * @param int $mtime - * @return bool - */ - public function touch($path, $mtime = null) { + public function touch(string $path, ?int $mtime = null): bool { return $this->storage->touch($this->findPathToUse($path), $mtime); } - /** - * get the path to a local version of the file. - * The local version of the file can be temporary and doesn't have to be persistent across requests - * - * @param string $path - * @return string|bool - */ - public function getLocalFile($path) { + public function getLocalFile(string $path): string|false { return $this->storage->getLocalFile($this->findPathToUse($path)); } - /** - * check if a file or folder has been updated since $time - * - * @param string $path - * @param int $time - * @return bool - * - * hasUpdated for folders should return at least true if a file inside the folder is add, removed or renamed. - * returning true for other changes in the folder is optional - */ - public function hasUpdated($path, $time) { + public function hasUpdated(string $path, int $time): bool { return $this->storage->hasUpdated($this->findPathToUse($path), $time); } - /** - * get a cache instance for the storage - * - * @param string $path - * @param \OC\Files\Storage\Storage (optional) the storage to pass to the cache - * @return \OC\Files\Cache\Cache - */ - public function getCache($path = '', $storage = null) { + public function getCache(string $path = '', ?IStorage $storage = null): \OCP\Files\Cache\ICache { if (!$storage) { $storage = $this; } return $this->storage->getCache($this->findPathToUse($path), $storage); } - /** - * get a scanner instance for the storage - * - * @param string $path - * @param \OC\Files\Storage\Storage (optional) the storage to pass to the scanner - * @return \OC\Files\Cache\Scanner - */ - public function getScanner($path = '', $storage = null) { + public function getScanner(string $path = '', ?IStorage $storage = null): IScanner { if (!$storage) { $storage = $this; } return $this->storage->getScanner($this->findPathToUse($path), $storage); } - /** - * get the ETag for a file or folder - * - * @param string $path - * @return string|bool - */ - public function getETag($path) { + public function getETag(string $path): string|false { return $this->storage->getETag($this->findPathToUse($path)); } - /** - * @param IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @return bool - */ - public function copyFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { if ($sourceStorage === $this) { return $this->copy($sourceInternalPath, $this->findPathToUse($targetInternalPath)); } @@ -508,13 +260,7 @@ class Encoding extends Wrapper { return $result; } - /** - * @param IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @return bool - */ - public function moveFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { if ($sourceStorage === $this) { $result = $this->rename($sourceInternalPath, $this->findPathToUse($targetInternalPath)); if ($result) { @@ -532,13 +278,15 @@ class Encoding extends Wrapper { return $result; } - public function getMetaData($path) { + public function getMetaData(string $path): ?array { $entry = $this->storage->getMetaData($this->findPathToUse($path)); - $entry['name'] = trim(Filesystem::normalizePath($entry['name']), '/'); + if ($entry !== null) { + $entry['name'] = trim(Filesystem::normalizePath($entry['name']), '/'); + } return $entry; } - public function getDirectoryContent($directory): \Traversable { + public function getDirectoryContent(string $directory): \Traversable { $entries = $this->storage->getDirectoryContent($this->findPathToUse($directory)); foreach ($entries as $entry) { $entry['name'] = trim(Filesystem::normalizePath($entry['name']), '/'); diff --git a/lib/private/Files/Storage/Wrapper/EncodingDirectoryWrapper.php b/lib/private/Files/Storage/Wrapper/EncodingDirectoryWrapper.php index fd0d2707f8d..0a90b49f0f1 100644 --- a/lib/private/Files/Storage/Wrapper/EncodingDirectoryWrapper.php +++ b/lib/private/Files/Storage/Wrapper/EncodingDirectoryWrapper.php @@ -1,26 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2021, Nextcloud GmbH. - * - * @author Robin Appelman <robin@icewind.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OC\Files\Storage\Wrapper; use Icewind\Streams\DirectoryWrapper; @@ -30,11 +13,7 @@ use OC\Files\Filesystem; * Normalize file names while reading directory entries */ class EncodingDirectoryWrapper extends DirectoryWrapper { - /** - * @psalm-suppress ImplementedReturnTypeMismatch Until return type is fixed upstream - * @return string|false - */ - public function dir_readdir() { + public function dir_readdir(): string|false { $file = readdir($this->source); if ($file !== false && $file !== '.' && $file !== '..') { $file = trim(Filesystem::normalizePath($file), '/'); @@ -45,8 +24,7 @@ class EncodingDirectoryWrapper extends DirectoryWrapper { /** * @param resource $source - * @param callable $filter - * @return resource|bool + * @return resource|false */ public static function wrap($source) { return self::wrapSource($source, [ diff --git a/lib/private/Files/Storage/Wrapper/Encryption.php b/lib/private/Files/Storage/Wrapper/Encryption.php index 0bd799507ff..58bd4dfddcf 100644 --- a/lib/private/Files/Storage/Wrapper/Encryption.php +++ b/lib/private/Files/Storage/Wrapper/Encryption.php @@ -1,56 +1,29 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author J0WI <J0WI@users.noreply.github.com> - * @author jknockaert <jasper@knockaert.nl> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Piotr M <mrow4a@yahoo.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Tigran Mkrtchyan <tigran.mkrtchyan@desy.de> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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\Update; 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\GenericEncryptionException; +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; @@ -58,107 +31,67 @@ use Psr\Log\LoggerInterface; class Encryption extends Wrapper { use LocalTempFileTrait; - /** @var string */ - private $mountPoint; - - /** @var \OC\Encryption\Util */ - private $util; - - /** @var \OCP\Encryption\IManager */ - private $encryptionManager; - - private LoggerInterface $logger; - - /** @var string */ - private $uid; - - /** @var array */ - protected $unencryptedSize; - - /** @var \OCP\Encryption\IFile */ - private $fileHelper; - - /** @var IMountPoint */ - private $mount; - - /** @var IStorage */ - private $keyStorage; - - /** @var Update */ - private $update; - - /** @var Manager */ - private $mountManager; - - /** @var array remember for which path we execute the repair step to avoid recursions */ - private $fixUnencryptedSizeOf = []; - - /** @var ArrayCache */ - private $arrayCache; - + 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( - $parameters, - IManager $encryptionManager = null, - Util $util = null, - LoggerInterface $logger = null, - IFile $fileHelper = null, - $uid = null, - IStorage $keyStorage = null, - Update $update = null, - Manager $mountManager = null, - ArrayCache $arrayCache = null + 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->encryptionManager = $encryptionManager; - $this->util = $util; - $this->logger = $logger; - $this->uid = $uid; - $this->fileHelper = $fileHelper; - $this->keyStorage = $keyStorage; - $this->unencryptedSize = []; - $this->update = $update; - $this->mountManager = $mountManager; - $this->arrayCache = $arrayCache; $this->encryptedPaths = new CappedMemoryCache(); parent::__construct($parameters); } - /** - * see https://www.php.net/manual/en/function.filesize.php - * The result for filesize when called on a folder is required to be 0 - * - * @param string $path - * @return int - */ - public function filesize($path) { + public function filesize(string $path): int|float|false { $fullPath = $this->getFullPath($path); - /** @var CacheEntry $info */ $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 - if ($info instanceof ICacheEntry) { - $info['encrypted'] = $info['encryptedVersion']; - } else { - if (!is_array($info)) { - $info = []; + + // 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); } - $info['encrypted'] = true; - $info = new CacheEntry($info); - } - if ($size !== $info->getUnencryptedSize()) { - $this->getCache()->update($info->getId(), [ - 'unencrypted_size' => $size - ]); + if ($size !== $info->getUnencryptedSize()) { + $this->getCache()->update($info->getId(), [ + 'unencrypted_size' => $size + ]); + } } return $size; @@ -171,11 +104,6 @@ class Encryption extends Wrapper { return $this->storage->filesize($path); } - /** - * @param string $path - * @param array $data - * @return array - */ private function modifyMetaData(string $path, array $data): array { $fullPath = $this->getFullPath($path); $info = $this->getCache()->get($path); @@ -183,10 +111,12 @@ class Encryption extends Wrapper { 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']; } } @@ -197,7 +127,7 @@ class Encryption extends Wrapper { return $data; } - public function getMetaData($path) { + public function getMetaData(string $path): ?array { $data = $this->storage->getMetaData($path); if (is_null($data)) { return null; @@ -205,24 +135,18 @@ class Encryption extends Wrapper { return $this->modifyMetaData($path, $data); } - public function getDirectoryContent($directory): \Traversable { + public function getDirectoryContent(string $directory): \Traversable { $parent = rtrim($directory, '/'); foreach ($this->getWrapperStorage()->getDirectoryContent($directory) as $data) { yield $this->modifyMetaData($parent . '/' . $data['name'], $data); } } - /** - * see https://www.php.net/manual/en/function.file_get_contents.php - * - * @param string $path - * @return string - */ - public function file_get_contents($path) { + public function file_get_contents(string $path): string|false { $encryptionModule = $this->getEncryptionModule($path); if ($encryptionModule) { - $handle = $this->fopen($path, "r"); + $handle = $this->fopen($path, 'r'); if (!$handle) { return false; } @@ -233,14 +157,7 @@ class Encryption extends Wrapper { return $this->storage->file_get_contents($path); } - /** - * see https://www.php.net/manual/en/function.file_put_contents.php - * - * @param string $path - * @param mixed $data - * @return int|false - */ - public function file_put_contents($path, $data) { + 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)) { @@ -252,13 +169,7 @@ class Encryption extends Wrapper { return false; } - /** - * see https://www.php.net/manual/en/function.unlink.php - * - * @param string $path - * @return bool - */ - public function unlink($path) { + public function unlink(string $path): bool { $fullPath = $this->getFullPath($path); if ($this->util->isExcluded($fullPath)) { return $this->storage->unlink($path); @@ -272,21 +183,14 @@ class Encryption extends Wrapper { return $this->storage->unlink($path); } - /** - * see https://www.php.net/manual/en/function.rename.php - * - * @param string $source - * @param string $target - * @return bool - */ - public function rename($source, $target) { + public function rename(string $source, string $target): bool { $result = $this->storage->rename($source, $target); - if ($result && + 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()) { + && $this->isVersion($target) === false + && $this->encryptionManager->isEnabled()) { $sourcePath = $this->getFullPath($source); if (!$this->util->isExcluded($sourcePath)) { $targetPath = $this->getFullPath($target); @@ -304,18 +208,12 @@ class Encryption extends Wrapper { return $result; } - /** - * see https://www.php.net/manual/en/function.rmdir.php - * - * @param string $path - * @return bool - */ - public function rmdir($path) { + 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() + if ($result + && $this->util->isExcluded($fullPath) === false + && $this->encryptionManager->isEnabled() ) { $this->keyStorage->deleteAllFileKeys($fullPath); } @@ -323,20 +221,14 @@ class Encryption extends Wrapper { return $result; } - /** - * check if a file can be read - * - * @param string $path - * @return bool - */ - public function isReadable($path) { + public function isReadable(string $path): bool { $isReadable = true; $metaData = $this->getMetaData($path); if ( - !$this->is_dir($path) && - isset($metaData['encrypted']) && - $metaData['encrypted'] === true + !$this->is_dir($path) + && isset($metaData['encrypted']) + && $metaData['encrypted'] === true ) { $fullPath = $this->getFullPath($path); $module = $this->getEncryptionModule($path); @@ -346,13 +238,7 @@ class Encryption extends Wrapper { return $this->storage->isReadable($path) && $isReadable; } - /** - * see https://www.php.net/manual/en/function.copy.php - * - * @param string $source - * @param string $target - */ - public function copy($source, $target): bool { + public function copy(string $source, string $target): bool { $sourcePath = $this->getFullPath($source); if ($this->util->isExcluded($sourcePath)) { @@ -365,16 +251,7 @@ class Encryption extends Wrapper { return $this->copyFromStorage($this, $source, $target); } - /** - * see https://www.php.net/manual/en/function.fopen.php - * - * @param string $path - * @param string $mode - * @return resource|bool - * @throws GenericEncryptionException - * @throws ModuleDoesNotExistsException - */ - public function fopen($path, $mode) { + 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 @@ -383,6 +260,10 @@ class Encryption extends Wrapper { return $this->storage->fopen($path, $mode); } + if (!$this->enabled) { + return $this->storage->fopen($path, $mode); + } + $encryptionEnabled = $this->encryptionManager->isEnabled(); $shouldEncrypt = false; $encryptionModule = null; @@ -423,10 +304,8 @@ class Encryption extends Wrapper { // if we update a encrypted file with a un-encrypted one we change the db flag if ($targetIsEncrypted && $encryptionEnabled === false) { $cache = $this->storage->getCache(); - if ($cache) { - $entry = $cache->get($path); - $cache->update($entry->getId(), ['encrypted' => 0]); - } + $entry = $cache->get($path); + $cache->update($entry->getId(), ['encrypted' => 0]); } if ($encryptionEnabled) { // if $encryptionModuleId is empty, the default module will be used @@ -441,7 +320,7 @@ class Encryption extends Wrapper { if (!empty($encryptionModuleId)) { $encryptionModule = $this->encryptionManager->getEncryptionModule($encryptionModuleId); $shouldEncrypt = true; - } elseif (empty($encryptionModuleId) && $info['encrypted'] === 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 @@ -467,6 +346,16 @@ class Encryption extends Wrapper { 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; @@ -484,7 +373,7 @@ class Encryption extends Wrapper { /** - * perform some plausibility checks if the the unencrypted size is correct. + * 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 @@ -496,8 +385,9 @@ class Encryption extends Wrapper { $size = $this->storage->filesize($path); $result = $unencryptedSize; - if ($unencryptedSize < 0 || - ($size > 0 && $unencryptedSize === $size) + if ($unencryptedSize < 0 + || ($size > 0 && $unencryptedSize === $size) + || $unencryptedSize > $size ) { // check if we already calculate the unencrypted size for the // given path to avoid recursions @@ -521,10 +411,8 @@ class Encryption extends Wrapper { * @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 - * - * @return int calculated unencrypted size */ - protected function fixUnencryptedSize(string $path, int $size, int $unencryptedSize): int { + protected function fixUnencryptedSize(string $path, int $size, int $unencryptedSize): int|float { $headerSize = $this->getHeaderSize($path); $header = $this->getHeader($path); $encryptionModule = $this->getEncryptionModule($path); @@ -593,12 +481,10 @@ class Encryption extends Wrapper { // write to cache if applicable $cache = $this->storage->getCache(); - if ($cache) { - $entry = $cache->get($path); - $cache->update($entry['fileid'], [ - 'unencrypted_size' => $newUnencryptedSize - ]); - } + $entry = $cache->get($path); + $cache->update($entry['fileid'], [ + 'unencrypted_size' => $newUnencryptedSize + ]); return $newUnencryptedSize; } @@ -612,7 +498,7 @@ class Encryption extends Wrapper { * 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 handle the stream to read from + * @param resource $handle the stream to read from * @param int $blockSize Length of requested data block in bytes * @return string Data fetched from stream. */ @@ -630,19 +516,12 @@ class Encryption extends Wrapper { return $data; } - /** - * @param Storage\IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @param bool $preserveMtime - * @return bool - */ public function moveFromStorage( Storage\IStorage $sourceStorage, - $sourceInternalPath, - $targetInternalPath, - $preserveMtime = true - ) { + string $sourceInternalPath, + string $targetInternalPath, + $preserveMtime = true, + ): bool { if ($sourceStorage === $this) { return $this->rename($sourceInternalPath, $targetInternalPath); } @@ -659,31 +538,34 @@ class Encryption extends Wrapper { $result = $this->copyBetweenStorage($sourceStorage, $sourceInternalPath, $targetInternalPath, $preserveMtime, true); if ($result) { - if ($sourceStorage->is_dir($sourceInternalPath)) { - $result &= $sourceStorage->rmdir($sourceInternalPath); - } else { - $result &= $sourceStorage->unlink($sourceInternalPath); + $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; } - - /** - * @param Storage\IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @param bool $preserveMtime - * @param bool $isRename - * @return bool - */ public function copyFromStorage( Storage\IStorage $sourceStorage, - $sourceInternalPath, - $targetInternalPath, + string $sourceInternalPath, + string $targetInternalPath, $preserveMtime = false, - $isRename = 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 @@ -695,20 +577,14 @@ class Encryption extends Wrapper { /** * Update the encrypted cache version in the database - * - * @param Storage\IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @param bool $isRename - * @param bool $keepEncryptionVersion */ private function updateEncryptedVersion( Storage\IStorage $sourceStorage, - $sourceInternalPath, - $targetInternalPath, - $isRename, - $keepEncryptionVersion - ) { + string $sourceInternalPath, + string $targetInternalPath, + bool $isRename, + bool $keepEncryptionVersion, + ): void { $isEncrypted = $this->encryptionManager->isEnabled() && $this->shouldEncrypt($targetInternalPath); $cacheInformation = [ 'encrypted' => $isEncrypted, @@ -748,26 +624,19 @@ class Encryption extends Wrapper { /** * copy file between two storages - * - * @param Storage\IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @param bool $preserveMtime - * @param bool $isRename - * @return bool * @throws \Exception */ private function copyBetweenStorage( Storage\IStorage $sourceStorage, - $sourceInternalPath, - $targetInternalPath, - $preserveMtime, - $isRename - ) { + 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)) { + 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 @@ -790,9 +659,8 @@ class Encryption extends Wrapper { // 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. - $mount = $this->mountManager->findByStorageId($sourceStorage->getId()); - if (count($mount) === 1) { - $mountPoint = $mount[0]->getMountPoint(); + 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); @@ -808,9 +676,9 @@ class Encryption extends Wrapper { $result = true; } if (is_resource($dh)) { - while ($result and ($file = readdir($dh)) !== false) { + while ($result && ($file = readdir($dh)) !== false) { if (!Filesystem::isIgnoredDir($file)) { - $result &= $this->copyFromStorage($sourceStorage, $sourceInternalPath . '/' . $file, $targetInternalPath . '/' . $file, false, $isRename); + $result = $this->copyFromStorage($sourceStorage, $sourceInternalPath . '/' . $file, $targetInternalPath . '/' . $file, $preserveMtime, $isRename); } } } @@ -818,12 +686,16 @@ class Encryption extends Wrapper { try { $source = $sourceStorage->fopen($sourceInternalPath, 'r'); $target = $this->fopen($targetInternalPath, 'w'); - [, $result] = \OC_Helper::streamCopy($source, $target); + if ($source === false || $target === false) { + $result = false; + } else { + [, $result] = Files::streamCopy($source, $target, true); + } } finally { - if (is_resource($source)) { + if (isset($source) && $source !== false) { fclose($source); } - if (is_resource($target)) { + if (isset($target) && $target !== false) { fclose($target); } } @@ -842,7 +714,7 @@ class Encryption extends Wrapper { return (bool)$result; } - public function getLocalFile($path) { + public function getLocalFile(string $path): string|false { if ($this->encryptionManager->isEnabled()) { $cachedFile = $this->getCachedFile($path); if (is_string($cachedFile)) { @@ -852,14 +724,14 @@ class Encryption extends Wrapper { return $this->storage->getLocalFile($path); } - public function isLocal() { + public function isLocal(): bool { if ($this->encryptionManager->isEnabled()) { return false; } return $this->storage->isLocal(); } - public function stat($path) { + public function stat(string $path): array|false { $stat = $this->storage->stat($path); if (!$stat) { return false; @@ -871,8 +743,11 @@ class Encryption extends Wrapper { return $stat; } - public function hash($type, $path, $raw = false) { + 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); @@ -885,21 +760,21 @@ class Encryption extends Wrapper { * @param string $path relative to mount point * @return string full path including mount point */ - protected function getFullPath($path) { + 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 - * - * @param string $path - * @return string */ - protected function readFirstBlock($path) { + 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); } @@ -908,11 +783,8 @@ class Encryption extends Wrapper { /** * return header size of given file - * - * @param string $path - * @return int */ - protected function getHeaderSize($path) { + protected function getHeaderSize(string $path): int { $headerSize = 0; $realFile = $this->util->stripPartialFileExtension($path); if ($this->storage->is_file($realFile)) { @@ -920,7 +792,7 @@ class Encryption extends Wrapper { } $firstBlock = $this->readFirstBlock($path); - if (substr($firstBlock, 0, strlen(Util::HEADER_START)) === Util::HEADER_START) { + if (str_starts_with($firstBlock, Util::HEADER_START)) { $headerSize = $this->util->getHeaderSize(); } @@ -928,40 +800,9 @@ class Encryption extends Wrapper { } /** - * parse raw header to array - * - * @param string $rawHeader - * @return array - */ - protected function parseRawHeader($rawHeader) { - $result = []; - if (substr($rawHeader, 0, strlen(Util::HEADER_START)) === Util::HEADER_START) { - $header = $rawHeader; - $endAt = strpos($header, Util::HEADER_END); - if ($endAt !== false) { - $header = substr($header, 0, $endAt + strlen(Util::HEADER_END)); - - // +1 to not start with an ':' which would result in empty element at the beginning - $exploded = explode(':', substr($header, strlen(Util::HEADER_START) + 1)); - - $element = array_shift($exploded); - while ($element !== Util::HEADER_END) { - $result[$element] = array_shift($exploded); - $element = array_shift($exploded); - } - } - } - - return $result; - } - - /** * read header from file - * - * @param string $path - * @return array */ - protected function getHeader($path) { + protected function getHeader(string $path): array { $realFile = $this->util->stripPartialFileExtension($path); $exists = $this->storage->is_file($realFile); if ($exists) { @@ -978,7 +819,7 @@ class Encryption extends Wrapper { if ($isEncrypted) { $firstBlock = $this->readFirstBlock($path); - $result = $this->parseRawHeader($firstBlock); + $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 @@ -993,12 +834,10 @@ class Encryption extends Wrapper { /** * read encryption module needed to read/write the file located at $path * - * @param string $path - * @return null|\OCP\Encryption\IEncryptionModule * @throws ModuleDoesNotExistsException * @throws \Exception */ - protected function getEncryptionModule($path) { + protected function getEncryptionModule(string $path): ?\OCP\Encryption\IEncryptionModule { $encryptionModule = null; $header = $this->getHeader($path); $encryptionModuleId = $this->util->getEncryptionModuleId($header); @@ -1014,11 +853,7 @@ class Encryption extends Wrapper { return $encryptionModule; } - /** - * @param string $path - * @param int $unencryptedSize - */ - public function updateUnencryptedSize($path, $unencryptedSize) { + public function updateUnencryptedSize(string $path, int|float $unencryptedSize): void { $this->unencryptedSize[$path] = $unencryptedSize; } @@ -1027,9 +862,8 @@ class Encryption extends Wrapper { * * @param string $source path relative to data/ * @param string $target path relative to data/ - * @return bool */ - protected function copyKeys($source, $target) { + protected function copyKeys(string $source, string $target): bool { if (!$this->util->isExcluded($source)) { return $this->keyStorage->copyKeys($source, $target); } @@ -1039,22 +873,16 @@ class Encryption extends Wrapper { /** * check if path points to a files version - * - * @param $path - * @return bool */ - protected function isVersion($path) { + 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 - * - * @param $path - * @return bool */ - protected function shouldEncrypt($path) { + protected function shouldEncrypt(string $path): bool { $fullPath = $this->getFullPath($path); $mountPointConfig = $this->mount->getOption('encrypt', true); if ($mountPointConfig === false) { @@ -1074,16 +902,19 @@ class Encryption extends Wrapper { return $encryptionModule->shouldEncrypt($fullPath); } - public function writeStream(string $path, $stream, int $size = null): int { + public function writeStream(string $path, $stream, ?int $size = null): int { // always fall back to fopen $target = $this->fopen($path, 'w'); - [$count, $result] = \OC_Helper::streamCopy($stream, $target); + 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)) { + if ($result && $this->getWrapperStorage()->instanceOfStorage(ObjectStoreStorage::class) && $this->shouldEncrypt($path)) { $this->getCache()->put($path, ['unencrypted_size' => $count]); } @@ -1093,4 +924,23 @@ class Encryption extends Wrapper { 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 index 9834ae5a954..38b113cef88 100644 --- a/lib/private/Files/Storage/Wrapper/Jail.php +++ b/lib/private/Files/Storage/Wrapper/Jail.php @@ -1,36 +1,20 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author J0WI <J0WI@users.noreply.github.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Tigran Mkrtchyan <tigran.mkrtchyan@desy.de> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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; @@ -47,32 +31,32 @@ class Jail extends Wrapper { protected $rootPath; /** - * @param array $arguments ['storage' => $storage, 'root' => $root] + * @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($arguments) { - parent::__construct($arguments); - $this->rootPath = $arguments['root']; + public function __construct(array $parameters) { + parent::__construct($parameters); + $this->rootPath = $parameters['root']; } - public function getUnjailedPath($path) { + 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() { + public function getUnjailedStorage(): IStorage { return $this->storage; } - public function getJailedPath($path) { + public function getJailedPath(string $path): ?string { $root = rtrim($this->rootPath, '/') . '/'; - if ($path !== $this->rootPath && strpos($path, $root) !== 0) { + if ($path !== $this->rootPath && !str_starts_with($path, $root)) { return null; } else { $path = substr($path, strlen($this->rootPath)); @@ -80,435 +64,178 @@ class Jail extends Wrapper { } } - public function getId() { + public function getId(): string { return parent::getId(); } - /** - * see https://www.php.net/manual/en/function.mkdir.php - * - * @param string $path - * @return bool - */ - public function mkdir($path) { + public function mkdir(string $path): bool { return $this->getWrapperStorage()->mkdir($this->getUnjailedPath($path)); } - /** - * see https://www.php.net/manual/en/function.rmdir.php - * - * @param string $path - * @return bool - */ - public function rmdir($path) { + public function rmdir(string $path): bool { return $this->getWrapperStorage()->rmdir($this->getUnjailedPath($path)); } - /** - * see https://www.php.net/manual/en/function.opendir.php - * - * @param string $path - * @return resource|bool - */ - public function opendir($path) { + public function opendir(string $path) { return $this->getWrapperStorage()->opendir($this->getUnjailedPath($path)); } - /** - * see https://www.php.net/manual/en/function.is_dir.php - * - * @param string $path - * @return bool - */ - public function is_dir($path) { + public function is_dir(string $path): bool { return $this->getWrapperStorage()->is_dir($this->getUnjailedPath($path)); } - /** - * see https://www.php.net/manual/en/function.is_file.php - * - * @param string $path - * @return bool - */ - public function is_file($path) { + public function is_file(string $path): bool { return $this->getWrapperStorage()->is_file($this->getUnjailedPath($path)); } - /** - * see https://www.php.net/manual/en/function.stat.php - * only the following keys are required in the result: size and mtime - * - * @param string $path - * @return array|bool - */ - public function stat($path) { + public function stat(string $path): array|false { return $this->getWrapperStorage()->stat($this->getUnjailedPath($path)); } - /** - * see https://www.php.net/manual/en/function.filetype.php - * - * @param string $path - * @return bool - */ - public function filetype($path) { + public function filetype(string $path): string|false { return $this->getWrapperStorage()->filetype($this->getUnjailedPath($path)); } - /** - * see https://www.php.net/manual/en/function.filesize.php - * The result for filesize when called on a folder is required to be 0 - * - * @param string $path - * @return int|bool - */ - public function filesize($path) { + public function filesize(string $path): int|float|false { return $this->getWrapperStorage()->filesize($this->getUnjailedPath($path)); } - /** - * check if a file can be created in $path - * - * @param string $path - * @return bool - */ - public function isCreatable($path) { + public function isCreatable(string $path): bool { return $this->getWrapperStorage()->isCreatable($this->getUnjailedPath($path)); } - /** - * check if a file can be read - * - * @param string $path - * @return bool - */ - public function isReadable($path) { + public function isReadable(string $path): bool { return $this->getWrapperStorage()->isReadable($this->getUnjailedPath($path)); } - /** - * check if a file can be written to - * - * @param string $path - * @return bool - */ - public function isUpdatable($path) { + public function isUpdatable(string $path): bool { return $this->getWrapperStorage()->isUpdatable($this->getUnjailedPath($path)); } - /** - * check if a file can be deleted - * - * @param string $path - * @return bool - */ - public function isDeletable($path) { + public function isDeletable(string $path): bool { return $this->getWrapperStorage()->isDeletable($this->getUnjailedPath($path)); } - /** - * check if a file can be shared - * - * @param string $path - * @return bool - */ - public function isSharable($path) { + public function isSharable(string $path): bool { return $this->getWrapperStorage()->isSharable($this->getUnjailedPath($path)); } - /** - * get the full permissions of a path. - * Should return a combination of the PERMISSION_ constants defined in lib/public/constants.php - * - * @param string $path - * @return int - */ - public function getPermissions($path) { + public function getPermissions(string $path): int { return $this->getWrapperStorage()->getPermissions($this->getUnjailedPath($path)); } - /** - * see https://www.php.net/manual/en/function.file_exists.php - * - * @param string $path - * @return bool - */ - public function file_exists($path) { + public function file_exists(string $path): bool { return $this->getWrapperStorage()->file_exists($this->getUnjailedPath($path)); } - /** - * see https://www.php.net/manual/en/function.filemtime.php - * - * @param string $path - * @return int|bool - */ - public function filemtime($path) { + public function filemtime(string $path): int|false { return $this->getWrapperStorage()->filemtime($this->getUnjailedPath($path)); } - /** - * see https://www.php.net/manual/en/function.file_get_contents.php - * - * @param string $path - * @return string|bool - */ - public function file_get_contents($path) { + public function file_get_contents(string $path): string|false { return $this->getWrapperStorage()->file_get_contents($this->getUnjailedPath($path)); } - /** - * see https://www.php.net/manual/en/function.file_put_contents.php - * - * @param string $path - * @param mixed $data - * @return int|false - */ - public function file_put_contents($path, $data) { + public function file_put_contents(string $path, mixed $data): int|float|false { return $this->getWrapperStorage()->file_put_contents($this->getUnjailedPath($path), $data); } - /** - * see https://www.php.net/manual/en/function.unlink.php - * - * @param string $path - * @return bool - */ - public function unlink($path) { + public function unlink(string $path): bool { return $this->getWrapperStorage()->unlink($this->getUnjailedPath($path)); } - /** - * see https://www.php.net/manual/en/function.rename.php - * - * @param string $source - * @param string $target - * @return bool - */ - public function rename($source, $target) { + public function rename(string $source, string $target): bool { return $this->getWrapperStorage()->rename($this->getUnjailedPath($source), $this->getUnjailedPath($target)); } - /** - * see https://www.php.net/manual/en/function.copy.php - * - * @param string $source - * @param string $target - * @return bool - */ - public function copy($source, $target) { + public function copy(string $source, string $target): bool { return $this->getWrapperStorage()->copy($this->getUnjailedPath($source), $this->getUnjailedPath($target)); } - /** - * see https://www.php.net/manual/en/function.fopen.php - * - * @param string $path - * @param string $mode - * @return resource|bool - */ - public function fopen($path, $mode) { + public function fopen(string $path, string $mode) { return $this->getWrapperStorage()->fopen($this->getUnjailedPath($path), $mode); } - /** - * get the mimetype for a file or folder - * The mimetype for a folder is required to be "httpd/unix-directory" - * - * @param string $path - * @return string|bool - */ - public function getMimeType($path) { + public function getMimeType(string $path): string|false { return $this->getWrapperStorage()->getMimeType($this->getUnjailedPath($path)); } - /** - * see https://www.php.net/manual/en/function.hash.php - * - * @param string $type - * @param string $path - * @param bool $raw - * @return string|bool - */ - public function hash($type, $path, $raw = false) { + public function hash(string $type, string $path, bool $raw = false): string|false { return $this->getWrapperStorage()->hash($type, $this->getUnjailedPath($path), $raw); } - /** - * see https://www.php.net/manual/en/function.free_space.php - * - * @param string $path - * @return int|bool - */ - public function free_space($path) { + public function free_space(string $path): int|float|false { return $this->getWrapperStorage()->free_space($this->getUnjailedPath($path)); } - /** - * search for occurrences of $query in file names - * - * @param string $query - * @return array|bool - */ - public function search($query) { - return $this->getWrapperStorage()->search($query); - } - - /** - * see https://www.php.net/manual/en/function.touch.php - * If the backend does not support the operation, false should be returned - * - * @param string $path - * @param int $mtime - * @return bool - */ - public function touch($path, $mtime = null) { + public function touch(string $path, ?int $mtime = null): bool { return $this->getWrapperStorage()->touch($this->getUnjailedPath($path), $mtime); } - /** - * get the path to a local version of the file. - * The local version of the file can be temporary and doesn't have to be persistent across requests - * - * @param string $path - * @return string|bool - */ - public function getLocalFile($path) { + public function getLocalFile(string $path): string|false { return $this->getWrapperStorage()->getLocalFile($this->getUnjailedPath($path)); } - /** - * check if a file or folder has been updated since $time - * - * @param string $path - * @param int $time - * @return bool - * - * hasUpdated for folders should return at least true if a file inside the folder is add, removed or renamed. - * returning true for other changes in the folder is optional - */ - public function hasUpdated($path, $time) { + public function hasUpdated(string $path, int $time): bool { return $this->getWrapperStorage()->hasUpdated($this->getUnjailedPath($path), $time); } - /** - * get a cache instance for the storage - * - * @param string $path - * @param \OC\Files\Storage\Storage|null (optional) the storage to pass to the cache - * @return \OC\Files\Cache\Cache - */ - public function getCache($path = '', $storage = null) { - if (!$storage) { - $storage = $this->getWrapperStorage(); - } - $sourceCache = $this->getWrapperStorage()->getCache($this->getUnjailedPath($path), $storage); + public function getCache(string $path = '', ?IStorage $storage = null): ICache { + $sourceCache = $this->getWrapperStorage()->getCache($this->getUnjailedPath($path)); return new CacheJail($sourceCache, $this->rootPath); } - /** - * get the user id of the owner of a file or folder - * - * @param string $path - * @return string - */ - public function getOwner($path) { + public function getOwner(string $path): string|false { return $this->getWrapperStorage()->getOwner($this->getUnjailedPath($path)); } - /** - * get a watcher instance for the cache - * - * @param string $path - * @param \OC\Files\Storage\Storage (optional) the storage to pass to the watcher - * @return \OC\Files\Cache\Watcher - */ - public function getWatcher($path = '', $storage = null) { - if (!$storage) { - $storage = $this; - } - return $this->getWrapperStorage()->getWatcher($this->getUnjailedPath($path), $storage); + public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher { + $sourceWatcher = $this->getWrapperStorage()->getWatcher($this->getUnjailedPath($path), $this->getWrapperStorage()); + return new JailWatcher($sourceWatcher, $this->rootPath); } - /** - * get the ETag for a file or folder - * - * @param string $path - * @return string|bool - */ - public function getETag($path) { + public function getETag(string $path): string|false { return $this->getWrapperStorage()->getETag($this->getUnjailedPath($path)); } - public function getMetaData($path) { + public function getMetaData(string $path): ?array { return $this->getWrapperStorage()->getMetaData($this->getUnjailedPath($path)); } - /** - * @param string $path - * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE - * @param \OCP\Lock\ILockingProvider $provider - * @throws \OCP\Lock\LockedException - */ - public function acquireLock($path, $type, ILockingProvider $provider) { + public function acquireLock(string $path, int $type, ILockingProvider $provider): void { $this->getWrapperStorage()->acquireLock($this->getUnjailedPath($path), $type, $provider); } - /** - * @param string $path - * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE - * @param \OCP\Lock\ILockingProvider $provider - */ - public function releaseLock($path, $type, ILockingProvider $provider) { + public function releaseLock(string $path, int $type, ILockingProvider $provider): void { $this->getWrapperStorage()->releaseLock($this->getUnjailedPath($path), $type, $provider); } - /** - * @param string $path - * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE - * @param \OCP\Lock\ILockingProvider $provider - */ - public function changeLock($path, $type, ILockingProvider $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 - * - * @param string $path - * @return array */ - public function resolvePath($path) { + public function resolvePath(string $path): array { return [$this->getWrapperStorage(), $this->getUnjailedPath($path)]; } - /** - * @param IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @return bool - */ - public function copyFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { + 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)); } - /** - * @param IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @return bool - */ - public function moveFromStorage(IStorage $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, $this->getUnjailedPath($targetInternalPath)); } - public function getPropagator($storage = null) { + public function getPropagator(?IStorage $storage = null): IPropagator { if (isset($this->propagator)) { return $this->propagator; } @@ -520,21 +247,21 @@ class Jail extends Wrapper { return $this->propagator; } - public function writeStream(string $path, $stream, int $size = null): int { + 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, $result] = \OC_Helper::streamCopy($stream, $target); + $count = Files::streamCopy($stream, $target); fclose($stream); fclose($target); return $count; } } - public function getDirectoryContent($directory): \Traversable { + 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 index 0d140e0a39d..684040146ba 100644 --- a/lib/private/Files/Storage/Wrapper/PermissionsMask.php +++ b/lib/private/Files/Storage/Wrapper/PermissionsMask.php @@ -1,34 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Stefan Weil <sw@weilnetz.de> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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 @@ -44,41 +25,41 @@ class PermissionsMask extends Wrapper { private $mask; /** - * @param array $arguments ['storage' => $storage, 'mask' => $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($arguments) { - parent::__construct($arguments); - $this->mask = $arguments['mask']; + public function __construct(array $parameters) { + parent::__construct($parameters); + $this->mask = $parameters['mask']; } - private function checkMask($permissions) { + private function checkMask(int $permissions): bool { return ($this->mask & $permissions) === $permissions; } - public function isUpdatable($path) { + public function isUpdatable(string $path): bool { return $this->checkMask(Constants::PERMISSION_UPDATE) and parent::isUpdatable($path); } - public function isCreatable($path) { + public function isCreatable(string $path): bool { return $this->checkMask(Constants::PERMISSION_CREATE) and parent::isCreatable($path); } - public function isDeletable($path) { + public function isDeletable(string $path): bool { return $this->checkMask(Constants::PERMISSION_DELETE) and parent::isDeletable($path); } - public function isSharable($path) { + public function isSharable(string $path): bool { return $this->checkMask(Constants::PERMISSION_SHARE) and parent::isSharable($path); } - public function getPermissions($path) { + public function getPermissions(string $path): int { return $this->storage->getPermissions($path) & $this->mask; } - public function rename($source, $target) { + 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); @@ -86,33 +67,33 @@ class PermissionsMask extends Wrapper { return $this->checkMask(Constants::PERMISSION_UPDATE) and parent::rename($source, $target); } - public function copy($source, $target) { + public function copy(string $source, string $target): bool { return $this->checkMask(Constants::PERMISSION_CREATE) and parent::copy($source, $target); } - public function touch($path, $mtime = null) { + 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($path) { + public function mkdir(string $path): bool { return $this->checkMask(Constants::PERMISSION_CREATE) and parent::mkdir($path); } - public function rmdir($path) { + public function rmdir(string $path): bool { return $this->checkMask(Constants::PERMISSION_DELETE) and parent::rmdir($path); } - public function unlink($path) { + public function unlink(string $path): bool { return $this->checkMask(Constants::PERMISSION_DELETE) and parent::unlink($path); } - public function file_put_contents($path, $data) { + 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($path, $mode) { + public function fopen(string $path, string $mode) { if ($mode === 'r' or $mode === 'rb') { return parent::fopen($path, $mode); } else { @@ -121,14 +102,7 @@ class PermissionsMask extends Wrapper { } } - /** - * get a cache instance for the storage - * - * @param string $path - * @param \OC\Files\Storage\Storage (optional) the storage to pass to the cache - * @return \OC\Files\Cache\Cache - */ - public function getCache($path = '', $storage = null) { + public function getCache(string $path = '', ?IStorage $storage = null): \OCP\Files\Cache\ICache { if (!$storage) { $storage = $this; } @@ -136,26 +110,26 @@ class PermissionsMask extends Wrapper { return new CachePermissionsMask($sourceCache, $this->mask); } - public function getMetaData($path) { + public function getMetaData(string $path): ?array { $data = parent::getMetaData($path); if ($data && isset($data['permissions'])) { - $data['scan_permissions'] = isset($data['scan_permissions']) ? $data['scan_permissions'] : $data['permissions']; + $data['scan_permissions'] = $data['scan_permissions'] ?? $data['permissions']; $data['permissions'] &= $this->mask; } return $data; } - public function getScanner($path = '', $storage = null) { + 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($directory): \Traversable { + public function getDirectoryContent(string $directory): \Traversable { foreach ($this->getWrapperStorage()->getDirectoryContent($directory) as $data) { - $data['scan_permissions'] = isset($data['scan_permissions']) ? $data['scan_permissions'] : $data['permissions']; + $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 index 5c542361c36..35a265f8c8e 100644 --- a/lib/private/Files/Storage/Wrapper/Quota.php +++ b/lib/private/Files/Storage/Wrapper/Quota.php @@ -1,34 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author J0WI <J0WI@users.noreply.github.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Julius Härtl <jus@bitgrid.net> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Tigran Mkrtchyan <tigran.mkrtchyan@desy.de> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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; @@ -41,29 +16,29 @@ use OCP\Files\Storage\IStorage; class Quota extends Wrapper { /** @var callable|null */ protected $quotaCallback; - protected ?int $quota; + /** @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($parameters) { + public function __construct(array $parameters) { parent::__construct($parameters); $this->quota = $parameters['quota'] ?? null; $this->quotaCallback = $parameters['quotaCallback'] ?? null; $this->sizeRoot = $parameters['root'] ?? ''; - $this->config = \OC::$server->get(SystemConfig::class); + $this->quotaIncludeExternalStorage = $parameters['include_external_storage'] ?? false; } - /** - * @return int quota value - */ - public function getQuota(): int { + public function getQuota(): int|float { if ($this->quota === null) { $quotaCallback = $this->quotaCallback; if ($quotaCallback === null) { - throw new \Exception("No quota or quota callback provider"); + throw new \Exception('No quota or quota callback provider'); } $this->quota = $quotaCallback(); } @@ -72,15 +47,14 @@ class Quota extends Wrapper { } private function hasQuota(): bool { + if (!$this->enabled) { + return false; + } return $this->getQuota() !== FileInfo::SPACE_UNLIMITED; } - /** - * @param string $path - * @param \OC\Files\Storage\Storage $storage - */ - protected function getSize($path, $storage = null) { - if ($this->config->getValue('quota_include_external_storage', false)) { + protected function getSize(string $path, ?IStorage $storage = null): int|float { + if ($this->quotaIncludeExternalStorage) { $rootInfo = Filesystem::getFileInfo('', 'ext'); if ($rootInfo) { return $rootInfo->getSize(true); @@ -97,17 +71,11 @@ class Quota extends Wrapper { } } - /** - * Get free space as limited by the quota - * - * @param string $path - * @return int|bool - */ - public function free_space($path) { + public function free_space(string $path): int|float|false { if (!$this->hasQuota()) { return $this->storage->free_space($path); } - if ($this->getQuota() < 0 || strpos($path, 'cache') === 0 || strpos($path, 'uploads') === 0) { + 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); @@ -123,14 +91,7 @@ class Quota extends Wrapper { } } - /** - * see https://www.php.net/manual/en/function.file_put_contents.php - * - * @param string $path - * @param mixed $data - * @return int|false - */ - public function file_put_contents($path, $data) { + public function file_put_contents(string $path, mixed $data): int|float|false { if (!$this->hasQuota()) { return $this->storage->file_put_contents($path, $data); } @@ -142,14 +103,7 @@ class Quota extends Wrapper { } } - /** - * see https://www.php.net/manual/en/function.copy.php - * - * @param string $source - * @param string $target - * @return bool - */ - public function copy($source, $target) { + public function copy(string $source, string $target): bool { if (!$this->hasQuota()) { return $this->storage->copy($source, $target); } @@ -161,14 +115,7 @@ class Quota extends Wrapper { } } - /** - * see https://www.php.net/manual/en/function.fopen.php - * - * @param string $path - * @param string $mode - * @return resource|bool - */ - public function fopen($path, $mode) { + public function fopen(string $path, string $mode) { if (!$this->hasQuota()) { return $this->storage->fopen($path, $mode); } @@ -177,7 +124,7 @@ class Quota extends Wrapper { // don't apply quota for part files if (!$this->isPartFile($path)) { $free = $this->free_space($path); - if ($source && is_int($free) && $free >= 0 && $mode !== 'r' && $mode !== 'rb') { + 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); @@ -192,10 +139,9 @@ class Quota extends Wrapper { * Checks whether the given path is a part file * * @param string $path Path that may identify a .part file - * @return string File path without .part extension * @note this is needed for reusing keys */ - private function isPartFile($path) { + private function isPartFile(string $path): bool { $extension = pathinfo($path, PATHINFO_EXTENSION); return ($extension === 'part'); @@ -204,17 +150,11 @@ class Quota extends Wrapper { /** * Only apply quota for files, not metadata, trash or others */ - private function shouldApplyQuota(string $path): bool { - return strpos(ltrim($path, '/'), 'files/') === 0; + protected function shouldApplyQuota(string $path): bool { + return str_starts_with(ltrim($path, '/'), 'files/'); } - /** - * @param IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @return bool - */ - public function copyFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { if (!$this->hasQuota()) { return $this->storage->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); } @@ -226,13 +166,7 @@ class Quota extends Wrapper { } } - /** - * @param IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @return bool - */ - public function moveFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { if (!$this->hasQuota()) { return $this->storage->moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); } @@ -244,7 +178,7 @@ class Quota extends Wrapper { } } - public function mkdir($path) { + public function mkdir(string $path): bool { if (!$this->hasQuota()) { return $this->storage->mkdir($path); } @@ -256,7 +190,7 @@ class Quota extends Wrapper { return parent::mkdir($path); } - public function touch($path, $mtime = null) { + public function touch(string $path, ?int $mtime = null): bool { if (!$this->hasQuota()) { return $this->storage->touch($path, $mtime); } @@ -267,4 +201,8 @@ class Quota extends Wrapper { 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 index ed7e137fd88..7af11dd5ef7 100644 --- a/lib/private/Files/Storage/Wrapper/Wrapper.php +++ b/lib/private/Files/Storage/Wrapper/Wrapper.php @@ -1,41 +1,26 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author J0WI <J0WI@users.noreply.github.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Tigran Mkrtchyan <tigran.mkrtchyan@desy.de> - * @author Vincent Petry <vincent@nextcloud.com> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * 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\InvalidPathException; +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 { /** @@ -52,444 +37,192 @@ class Wrapper implements \OC\Files\Storage\Storage, ILockingStorage, IWriteStrea /** * @param array $parameters */ - public function __construct($parameters) { + public function __construct(array $parameters) { $this->storage = $parameters['storage']; } - /** - * @return \OC\Files\Storage\Storage - */ - public function getWrapperStorage() { + 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; } - /** - * Get the identifier for the storage, - * the returned id should be the same for every storage object that is created with the same parameters - * and two storage objects with the same id should refer to two storages that display the same files. - * - * @return string - */ - public function getId() { + public function getId(): string { return $this->getWrapperStorage()->getId(); } - /** - * see https://www.php.net/manual/en/function.mkdir.php - * - * @param string $path - * @return bool - */ - public function mkdir($path) { + public function mkdir(string $path): bool { return $this->getWrapperStorage()->mkdir($path); } - /** - * see https://www.php.net/manual/en/function.rmdir.php - * - * @param string $path - * @return bool - */ - public function rmdir($path) { + public function rmdir(string $path): bool { return $this->getWrapperStorage()->rmdir($path); } - /** - * see https://www.php.net/manual/en/function.opendir.php - * - * @param string $path - * @return resource|bool - */ - public function opendir($path) { + public function opendir(string $path) { return $this->getWrapperStorage()->opendir($path); } - /** - * see https://www.php.net/manual/en/function.is_dir.php - * - * @param string $path - * @return bool - */ - public function is_dir($path) { + public function is_dir(string $path): bool { return $this->getWrapperStorage()->is_dir($path); } - /** - * see https://www.php.net/manual/en/function.is_file.php - * - * @param string $path - * @return bool - */ - public function is_file($path) { + public function is_file(string $path): bool { return $this->getWrapperStorage()->is_file($path); } - /** - * see https://www.php.net/manual/en/function.stat.php - * only the following keys are required in the result: size and mtime - * - * @param string $path - * @return array|bool - */ - public function stat($path) { + public function stat(string $path): array|false { return $this->getWrapperStorage()->stat($path); } - /** - * see https://www.php.net/manual/en/function.filetype.php - * - * @param string $path - * @return string|bool - */ - public function filetype($path) { + public function filetype(string $path): string|false { return $this->getWrapperStorage()->filetype($path); } - /** - * see https://www.php.net/manual/en/function.filesize.php - * The result for filesize when called on a folder is required to be 0 - * - * @param string $path - * @return int|bool - */ - public function filesize($path) { + public function filesize(string $path): int|float|false { return $this->getWrapperStorage()->filesize($path); } - /** - * check if a file can be created in $path - * - * @param string $path - * @return bool - */ - public function isCreatable($path) { + public function isCreatable(string $path): bool { return $this->getWrapperStorage()->isCreatable($path); } - /** - * check if a file can be read - * - * @param string $path - * @return bool - */ - public function isReadable($path) { + public function isReadable(string $path): bool { return $this->getWrapperStorage()->isReadable($path); } - /** - * check if a file can be written to - * - * @param string $path - * @return bool - */ - public function isUpdatable($path) { + public function isUpdatable(string $path): bool { return $this->getWrapperStorage()->isUpdatable($path); } - /** - * check if a file can be deleted - * - * @param string $path - * @return bool - */ - public function isDeletable($path) { + public function isDeletable(string $path): bool { return $this->getWrapperStorage()->isDeletable($path); } - /** - * check if a file can be shared - * - * @param string $path - * @return bool - */ - public function isSharable($path) { + public function isSharable(string $path): bool { return $this->getWrapperStorage()->isSharable($path); } - /** - * get the full permissions of a path. - * Should return a combination of the PERMISSION_ constants defined in lib/public/constants.php - * - * @param string $path - * @return int - */ - public function getPermissions($path) { + public function getPermissions(string $path): int { return $this->getWrapperStorage()->getPermissions($path); } - /** - * see https://www.php.net/manual/en/function.file_exists.php - * - * @param string $path - * @return bool - */ - public function file_exists($path) { + public function file_exists(string $path): bool { return $this->getWrapperStorage()->file_exists($path); } - /** - * see https://www.php.net/manual/en/function.filemtime.php - * - * @param string $path - * @return int|bool - */ - public function filemtime($path) { + public function filemtime(string $path): int|false { return $this->getWrapperStorage()->filemtime($path); } - /** - * see https://www.php.net/manual/en/function.file_get_contents.php - * - * @param string $path - * @return string|bool - */ - public function file_get_contents($path) { + public function file_get_contents(string $path): string|false { return $this->getWrapperStorage()->file_get_contents($path); } - /** - * see https://www.php.net/manual/en/function.file_put_contents.php - * - * @param string $path - * @param mixed $data - * @return int|false - */ - public function file_put_contents($path, $data) { + public function file_put_contents(string $path, mixed $data): int|float|false { return $this->getWrapperStorage()->file_put_contents($path, $data); } - /** - * see https://www.php.net/manual/en/function.unlink.php - * - * @param string $path - * @return bool - */ - public function unlink($path) { + public function unlink(string $path): bool { return $this->getWrapperStorage()->unlink($path); } - /** - * see https://www.php.net/manual/en/function.rename.php - * - * @param string $source - * @param string $target - * @return bool - */ - public function rename($source, $target) { + public function rename(string $source, string $target): bool { return $this->getWrapperStorage()->rename($source, $target); } - /** - * see https://www.php.net/manual/en/function.copy.php - * - * @param string $source - * @param string $target - * @return bool - */ - public function copy($source, $target) { + public function copy(string $source, string $target): bool { return $this->getWrapperStorage()->copy($source, $target); } - /** - * see https://www.php.net/manual/en/function.fopen.php - * - * @param string $path - * @param string $mode - * @return resource|bool - */ - public function fopen($path, $mode) { + public function fopen(string $path, string $mode) { return $this->getWrapperStorage()->fopen($path, $mode); } - /** - * get the mimetype for a file or folder - * The mimetype for a folder is required to be "httpd/unix-directory" - * - * @param string $path - * @return string|bool - */ - public function getMimeType($path) { + public function getMimeType(string $path): string|false { return $this->getWrapperStorage()->getMimeType($path); } - /** - * see https://www.php.net/manual/en/function.hash.php - * - * @param string $type - * @param string $path - * @param bool $raw - * @return string|bool - */ - public function hash($type, $path, $raw = false) { + public function hash(string $type, string $path, bool $raw = false): string|false { return $this->getWrapperStorage()->hash($type, $path, $raw); } - /** - * see https://www.php.net/manual/en/function.free_space.php - * - * @param string $path - * @return int|bool - */ - public function free_space($path) { + public function free_space(string $path): int|float|false { return $this->getWrapperStorage()->free_space($path); } - /** - * search for occurrences of $query in file names - * - * @param string $query - * @return array|bool - */ - public function search($query) { - return $this->getWrapperStorage()->search($query); - } - - /** - * see https://www.php.net/manual/en/function.touch.php - * If the backend does not support the operation, false should be returned - * - * @param string $path - * @param int $mtime - * @return bool - */ - public function touch($path, $mtime = null) { + public function touch(string $path, ?int $mtime = null): bool { return $this->getWrapperStorage()->touch($path, $mtime); } - /** - * get the path to a local version of the file. - * The local version of the file can be temporary and doesn't have to be persistent across requests - * - * @param string $path - * @return string|bool - */ - public function getLocalFile($path) { + public function getLocalFile(string $path): string|false { return $this->getWrapperStorage()->getLocalFile($path); } - /** - * check if a file or folder has been updated since $time - * - * @param string $path - * @param int $time - * @return bool - * - * hasUpdated for folders should return at least true if a file inside the folder is add, removed or renamed. - * returning true for other changes in the folder is optional - */ - public function hasUpdated($path, $time) { + public function hasUpdated(string $path, int $time): bool { return $this->getWrapperStorage()->hasUpdated($path, $time); } - /** - * get a cache instance for the storage - * - * @param string $path - * @param \OC\Files\Storage\Storage|null (optional) the storage to pass to the cache - * @return \OC\Files\Cache\Cache - */ - public function getCache($path = '', $storage = null) { + public function getCache(string $path = '', ?IStorage $storage = null): ICache { if (!$storage) { $storage = $this; } return $this->getWrapperStorage()->getCache($path, $storage); } - /** - * get a scanner instance for the storage - * - * @param string $path - * @param \OC\Files\Storage\Storage (optional) the storage to pass to the scanner - * @return \OC\Files\Cache\Scanner - */ - public function getScanner($path = '', $storage = null) { + public function getScanner(string $path = '', ?IStorage $storage = null): IScanner { if (!$storage) { $storage = $this; } return $this->getWrapperStorage()->getScanner($path, $storage); } - - /** - * get the user id of the owner of a file or folder - * - * @param string $path - * @return string - */ - public function getOwner($path) { + public function getOwner(string $path): string|false { return $this->getWrapperStorage()->getOwner($path); } - /** - * get a watcher instance for the cache - * - * @param string $path - * @param \OC\Files\Storage\Storage (optional) the storage to pass to the watcher - * @return \OC\Files\Cache\Watcher - */ - public function getWatcher($path = '', $storage = null) { + public function getWatcher(string $path = '', ?IStorage $storage = null): IWatcher { if (!$storage) { $storage = $this; } return $this->getWrapperStorage()->getWatcher($path, $storage); } - public function getPropagator($storage = null) { + public function getPropagator(?IStorage $storage = null): IPropagator { if (!$storage) { $storage = $this; } return $this->getWrapperStorage()->getPropagator($storage); } - public function getUpdater($storage = null) { + public function getUpdater(?IStorage $storage = null): IUpdater { if (!$storage) { $storage = $this; } return $this->getWrapperStorage()->getUpdater($storage); } - /** - * @return \OC\Files\Cache\Storage - */ - public function getStorageCache() { + public function getStorageCache(): \OC\Files\Cache\Storage { return $this->getWrapperStorage()->getStorageCache(); } - /** - * get the ETag for a file or folder - * - * @param string $path - * @return string|bool - */ - public function getETag($path) { + public function getETag(string $path): string|false { return $this->getWrapperStorage()->getETag($path); } - /** - * Returns true - * - * @return true - */ - public function test() { + public function test(): bool { return $this->getWrapperStorage()->test(); } - /** - * Returns the wrapped storage's value for isLocal() - * - * @return bool wrapped storage's isLocal() value - */ - public function isLocal() { + public function isLocal(): bool { return $this->getWrapperStorage()->isLocal(); } - /** - * Check if the storage is an instance of $class or is a wrapper for a storage that is an instance of $class - * - * @param class-string<IStorage> $class - * @return bool - */ - public function instanceOfStorage($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'; @@ -502,7 +235,7 @@ class Wrapper implements \OC\Files\Storage\Storage, ILockingStorage, IWriteStrea * @psalm-param class-string<T> $class * @psalm-return T|null */ - public function getInstanceOfStorage(string $class) { + public function getInstanceOfStorage(string $class): ?IStorage { $storage = $this; while ($storage instanceof Wrapper) { if ($storage instanceof $class) { @@ -519,61 +252,29 @@ class Wrapper implements \OC\Files\Storage\Storage, ILockingStorage, IWriteStrea /** * Pass any methods custom to specific storage implementations to the wrapped storage * - * @param string $method - * @param array $args * @return mixed */ - public function __call($method, $args) { + public function __call(string $method, array $args) { return call_user_func_array([$this->getWrapperStorage(), $method], $args); } - /** - * 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. - * - * @param string $path - * @return array|bool - */ - public function getDirectDownload($path) { + public function getDirectDownload(string $path): array|false { return $this->getWrapperStorage()->getDirectDownload($path); } - /** - * Get availability of the storage - * - * @return array [ available, last_checked ] - */ - public function getAvailability() { + public function getAvailability(): array { return $this->getWrapperStorage()->getAvailability(); } - /** - * Set availability of the storage - * - * @param bool $isAvailable - */ - public function setAvailability($isAvailable) { + public function setAvailability(bool $isAvailable): void { $this->getWrapperStorage()->setAvailability($isAvailable); } - /** - * @param string $path the path of the target folder - * @param string $fileName the name of the file itself - * @return void - * @throws InvalidPathException - */ - public function verifyPath($path, $fileName) { + public function verifyPath(string $path, string $fileName): void { $this->getWrapperStorage()->verifyPath($path, $fileName); } - /** - * @param IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @return bool - */ - public function copyFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { + public function copyFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { if ($sourceStorage === $this) { return $this->copy($sourceInternalPath, $targetInternalPath); } @@ -581,13 +282,7 @@ class Wrapper implements \OC\Files\Storage\Storage, ILockingStorage, IWriteStrea return $this->getWrapperStorage()->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); } - /** - * @param IStorage $sourceStorage - * @param string $sourceInternalPath - * @param string $targetInternalPath - * @return bool - */ - public function moveFromStorage(IStorage $sourceStorage, $sourceInternalPath, $targetInternalPath) { + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { if ($sourceStorage === $this) { return $this->rename($sourceInternalPath, $targetInternalPath); } @@ -595,66 +290,62 @@ class Wrapper implements \OC\Files\Storage\Storage, ILockingStorage, IWriteStrea return $this->getWrapperStorage()->moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); } - public function getMetaData($path) { + public function getMetaData(string $path): ?array { return $this->getWrapperStorage()->getMetaData($path); } - /** - * @param string $path - * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE - * @param \OCP\Lock\ILockingProvider $provider - * @throws \OCP\Lock\LockedException - */ - public function acquireLock($path, $type, ILockingProvider $provider) { + public function acquireLock(string $path, int $type, ILockingProvider $provider): void { if ($this->getWrapperStorage()->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { $this->getWrapperStorage()->acquireLock($path, $type, $provider); } } - /** - * @param string $path - * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE - * @param \OCP\Lock\ILockingProvider $provider - */ - public function releaseLock($path, $type, ILockingProvider $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); } } - /** - * @param string $path - * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE - * @param \OCP\Lock\ILockingProvider $provider - */ - public function changeLock($path, $type, ILockingProvider $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); } } - /** - * @return bool - */ - public function needsPartFile() { + public function needsPartFile(): bool { return $this->getWrapperStorage()->needsPartFile(); } - public function writeStream(string $path, $stream, int $size = null): int { + 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, $result] = \OC_Helper::streamCopy($stream, $target); + $count = Files::streamCopy($stream, $target); fclose($stream); fclose($target); return $count; } } - public function getDirectoryContent($directory): \Traversable { + 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); + } } diff --git a/lib/private/Files/Stream/Encryption.php b/lib/private/Files/Stream/Encryption.php index cebf7bafced..ef147ec421f 100644 --- a/lib/private/Files/Stream/Encryption.php +++ b/lib/private/Files/Stream/Encryption.php @@ -1,113 +1,52 @@ <?php + +declare(strict_types=1); + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author jknockaert <jasper@knockaert.nl> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author martink-p <47943787+martink-p@users.noreply.github.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Stream; use Icewind\Streams\Wrapper; use OC\Encryption\Exceptions\EncryptionHeaderKeyExistsException; +use OC\Encryption\File; +use OC\Encryption\Util; +use OC\Files\Storage\Storage; +use OCP\Encryption\IEncryptionModule; use function is_array; use function stream_context_create; class Encryption extends Wrapper { - /** @var \OC\Encryption\Util */ - protected $util; - - /** @var \OC\Encryption\File */ - protected $file; - - /** @var \OCP\Encryption\IEncryptionModule */ - protected $encryptionModule; - - /** @var \OC\Files\Storage\Storage */ - protected $storage; - - /** @var \OC\Files\Storage\Wrapper\Encryption */ - protected $encryptionStorage; - - /** @var string */ - protected $internalPath; - - /** @var string */ - protected $cache; - - /** @var integer */ - protected $size; - - /** @var integer */ - protected $position; - - /** @var integer */ - protected $unencryptedSize; - - /** @var integer */ - protected $headerSize; - - /** @var integer */ - protected $unencryptedBlockSize; - - /** @var array */ - protected $header; - - /** @var string */ - protected $fullPath; - - /** @var bool */ - protected $signed; - + protected Util $util; + protected File $file; + protected IEncryptionModule $encryptionModule; + protected Storage $storage; + protected \OC\Files\Storage\Wrapper\Encryption $encryptionStorage; + protected string $internalPath; + protected string $cache; + protected ?int $size = null; + protected int $position; + protected ?int $unencryptedSize = null; + protected int $headerSize; + protected int $unencryptedBlockSize; + protected array $header; + protected string $fullPath; + protected bool $signed; /** * header data returned by the encryption module, will be written to the file * in case of a write operation - * - * @var array */ - protected $newHeader; - + protected array $newHeader; /** * user who perform the read/write operation null for public access - * - * @var string */ - protected $uid; - - /** @var bool */ - protected $readOnly; - - /** @var bool */ - protected $writeFlag; - - /** @var array */ - protected $expectedContextProperties; - - /** @var bool */ - protected $fileUpdated; + protected ?string $uid; + protected bool $readOnly; + protected bool $writeFlag; + protected array $expectedContextProperties; + protected bool $fileUpdated; public function __construct() { $this->expectedContextProperties = [ @@ -137,14 +76,14 @@ class Encryption extends Wrapper { * @param string $fullPath relative to data/ * @param array $header * @param string $uid - * @param \OCP\Encryption\IEncryptionModule $encryptionModule - * @param \OC\Files\Storage\Storage $storage + * @param IEncryptionModule $encryptionModule + * @param Storage $storage * @param \OC\Files\Storage\Wrapper\Encryption $encStorage - * @param \OC\Encryption\Util $util - * @param \OC\Encryption\File $file + * @param Util $util + * @param File $file * @param string $mode - * @param int $size - * @param int $unencryptedSize + * @param int|float $size + * @param int|float $unencryptedSize * @param int $headerSize * @param bool $signed * @param string $wrapper stream wrapper class @@ -152,19 +91,24 @@ class Encryption extends Wrapper { * * @throws \BadMethodCallException */ - public static function wrap($source, $internalPath, $fullPath, array $header, - $uid, - \OCP\Encryption\IEncryptionModule $encryptionModule, - \OC\Files\Storage\Storage $storage, - \OC\Files\Storage\Wrapper\Encryption $encStorage, - \OC\Encryption\Util $util, - \OC\Encryption\File $file, - $mode, - $size, - $unencryptedSize, - $headerSize, - $signed, - $wrapper = Encryption::class) { + public static function wrap( + $source, + $internalPath, + $fullPath, + array $header, + $uid, + IEncryptionModule $encryptionModule, + Storage $storage, + \OC\Files\Storage\Wrapper\Encryption $encStorage, + Util $util, + File $file, + $mode, + $size, + $unencryptedSize, + $headerSize, + $signed, + $wrapper = Encryption::class, + ) { $context = stream_context_create([ 'ocencryption' => [ 'source' => $source, @@ -321,7 +265,7 @@ class Encryption extends Wrapper { $result .= substr($this->cache, $blockPosition, $remainingLength); $this->position += $remainingLength; $count = 0; - // otherwise remainder of current block is fetched, the block is flushed and the position updated + // otherwise remainder of current block is fetched, the block is flushed and the position updated } else { $result .= substr($this->cache, $blockPosition); $this->flush(); @@ -368,8 +312,8 @@ class Encryption extends Wrapper { // for seekable streams the pointer is moved back to the beginning of the encrypted block // flush will start writing there when the position moves to another block - $positionInFile = (int)floor($this->position / $this->unencryptedBlockSize) * - $this->util->getBlockSize() + $this->headerSize; + $positionInFile = (int)floor($this->position / $this->unencryptedBlockSize) + * $this->util->getBlockSize() + $this->headerSize; $resultFseek = $this->parentStreamSeek($positionInFile); // only allow writes on seekable streams, or at the end of the encrypted stream @@ -389,11 +333,11 @@ class Encryption extends Wrapper { $this->position += $remainingLength; $length += $remainingLength; $data = ''; - // if $data doesn't fit the current block, the fill the current block and reiterate - // after the block is filled, it is flushed and $data is updatedxxx + // if $data doesn't fit the current block, the fill the current block and reiterate + // after the block is filled, it is flushed and $data is updatedxxx } else { - $this->cache = substr($this->cache, 0, $blockPosition) . - substr($data, 0, $this->unencryptedBlockSize - $blockPosition); + $this->cache = substr($this->cache, 0, $blockPosition) + . substr($data, 0, $this->unencryptedBlockSize - $blockPosition); $this->flush(); $this->position += ($this->unencryptedBlockSize - $blockPosition); $length += ($this->unencryptedBlockSize - $blockPosition); diff --git a/lib/private/Files/Stream/HashWrapper.php b/lib/private/Files/Stream/HashWrapper.php index 4060d74de7d..5956ad92549 100644 --- a/lib/private/Files/Stream/HashWrapper.php +++ b/lib/private/Files/Stream/HashWrapper.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Stream; diff --git a/lib/private/Files/Stream/Quota.php b/lib/private/Files/Stream/Quota.php index 1549d95eba8..cc737910fd8 100644 --- a/lib/private/Files/Stream/Quota.php +++ b/lib/private/Files/Stream/Quota.php @@ -1,27 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Stream; @@ -41,7 +23,7 @@ class Quota extends Wrapper { /** * @param resource $stream * @param int $limit - * @return bool|resource + * @return resource|false */ public static function wrap($stream, $limit) { $context = stream_context_create([ diff --git a/lib/private/Files/Stream/SeekableHttpStream.php b/lib/private/Files/Stream/SeekableHttpStream.php index 51ccaeba998..6ce0a880e8d 100644 --- a/lib/private/Files/Stream/SeekableHttpStream.php +++ b/lib/private/Files/Stream/SeekableHttpStream.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2020, Lukas Stabe (lukas@stabe.de) - * - * @author Lukas Stabe <lukas@stabe.de> - * @author Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Stream; @@ -108,7 +91,7 @@ class SeekableHttpStream implements File { continue 2; } } - throw new \Exception("Failed to get source stream from stream wrapper of " . get_class($responseHead)); + throw new \Exception('Failed to get source stream from stream wrapper of ' . get_class($responseHead)); } $rangeHeaders = array_values(array_filter($responseHead, function ($v) { @@ -219,7 +202,9 @@ class SeekableHttpStream implements File { public function stream_stat() { if ($this->getCurrent()) { $stat = fstat($this->getCurrent()); - $stat['size'] = $this->totalSize; + if ($stat) { + $stat['size'] = $this->totalSize; + } return $stat; } else { return false; diff --git a/lib/private/Files/Template/TemplateManager.php b/lib/private/Files/Template/TemplateManager.php index 4603c14278f..80ef5a14786 100644 --- a/lib/private/Files/Template/TemplateManager.php +++ b/lib/private/Files/Template/TemplateManager.php @@ -3,27 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2021 Julius Härtl <jus@bitgrid.net> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Julius Härtl <jus@bitgrid.net> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Files\Template; @@ -31,15 +12,18 @@ use OC\AppFramework\Bootstrap\Coordinator; use OC\Files\Cache\Scanner; use OC\Files\Filesystem; use OCP\EventDispatcher\IEventDispatcher; -use OCP\Files\Folder; use OCP\Files\File; +use OCP\Files\Folder; use OCP\Files\GenericFileException; use OCP\Files\IRootFolder; use OCP\Files\Node; use OCP\Files\NotFoundException; +use OCP\Files\Template\BeforeGetTemplatesEvent; +use OCP\Files\Template\Field; use OCP\Files\Template\FileCreatedFromTemplateEvent; use OCP\Files\Template\ICustomTemplateProvider; use OCP\Files\Template\ITemplateManager; +use OCP\Files\Template\RegisterTemplateCreatorEvent; use OCP\Files\Template\Template; use OCP\Files\Template\TemplateFileCreator; use OCP\IConfig; @@ -80,7 +64,7 @@ class TemplateManager implements ITemplateManager { IPreview $previewManager, IConfig $config, IFactory $l10nFactory, - LoggerInterface $logger + LoggerInterface $logger, ) { $this->serverContainer = $serverContainer; $this->eventDispatcher = $eventDispatcher; @@ -119,6 +103,7 @@ class TemplateManager implements ITemplateManager { if (!empty($this->types)) { return $this->types; } + $this->eventDispatcher->dispatchTyped(new RegisterTemplateCreatorEvent($this)); foreach ($this->registeredTypes as $registeredType) { $this->types[] = $registeredType(); } @@ -134,20 +119,34 @@ class TemplateManager implements ITemplateManager { } public function listTemplates(): array { - return array_map(function (TemplateFileCreator $entry) { + return array_values(array_map(function (TemplateFileCreator $entry) { return array_merge($entry->jsonSerialize(), [ 'templates' => $this->getTemplateFiles($entry) ]); - }, $this->listCreators()); + }, $this->listCreators())); + } + + public function listTemplateFields(int $fileId): array { + foreach ($this->listCreators() as $creator) { + $fields = $this->getTemplateFields($creator, $fileId); + if (empty($fields)) { + continue; + } + + return $fields; + } + + return []; } /** * @param string $filePath * @param string $templateId + * @param array $templateFields * @return array * @throws GenericFileException */ - public function createFromTemplate(string $filePath, string $templateId = '', string $templateType = 'user'): array { + public function createFromTemplate(string $filePath, string $templateId = '', string $templateType = 'user', array $templateFields = []): array { $userFolder = $this->rootFolder->getUserFolder($this->userId); try { $userFolder->get($filePath); @@ -159,11 +158,9 @@ class TemplateManager implements ITemplateManager { throw new GenericFileException($this->l10n->t('Invalid path')); } $folder = $userFolder->get(dirname($filePath)); - $targetFile = $folder->newFile(basename($filePath)); $template = null; if ($templateType === 'user' && $templateId !== '') { $template = $userFolder->get($templateId); - $template->copy($targetFile->getPath()); } else { $matchingProvider = array_filter($this->getRegisteredProviders(), function (ICustomTemplateProvider $provider) use ($templateType) { return $templateType === get_class($provider); @@ -171,10 +168,12 @@ class TemplateManager implements ITemplateManager { $provider = array_shift($matchingProvider); if ($provider) { $template = $provider->getCustomTemplate($templateId); - $template->copy($targetFile->getPath()); } } - $this->eventDispatcher->dispatchTyped(new FileCreatedFromTemplateEvent($template, $targetFile)); + + $targetFile = $folder->newFile(basename($filePath), ($template instanceof File ? $template->fopen('rb') : null)); + + $this->eventDispatcher->dispatchTyped(new FileCreatedFromTemplateEvent($template, $targetFile, $templateFields)); return $this->formatFile($userFolder->get($filePath)); } catch (\Exception $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); @@ -188,14 +187,34 @@ class TemplateManager implements ITemplateManager { * @throws \OCP\Files\NotPermittedException * @throws \OC\User\NoUserException */ - private function getTemplateFolder(): Node { + private function getTemplateFolder(): Folder { if ($this->getTemplatePath() !== '') { - return $this->rootFolder->getUserFolder($this->userId)->get($this->getTemplatePath()); + $path = $this->rootFolder->getUserFolder($this->userId)->get($this->getTemplatePath()); + if ($path instanceof Folder) { + return $path; + } } throw new NotFoundException(); } + /** + * @return list<Template> + */ private function getTemplateFiles(TemplateFileCreator $type): array { + $templates = array_merge( + $this->getProviderTemplates($type), + $this->getUserTemplates($type) + ); + + $this->eventDispatcher->dispatchTyped(new BeforeGetTemplatesEvent($templates, false)); + + return $templates; + } + + /** + * @return list<Template> + */ + private function getProviderTemplates(TemplateFileCreator $type): array { $templates = []; foreach ($this->getRegisteredProviders() as $provider) { foreach ($type->getMimetypes() as $mimetype) { @@ -204,11 +223,22 @@ class TemplateManager implements ITemplateManager { } } } + + return $templates; + } + + /** + * @return list<Template> + */ + private function getUserTemplates(TemplateFileCreator $type): array { + $templates = []; + try { $userTemplateFolder = $this->getTemplateFolder(); } catch (\Exception $e) { return $templates; } + foreach ($type->getMimetypes() as $mimetype) { foreach ($userTemplateFolder->searchByMime($mimetype) as $templateFile) { $template = new Template( @@ -224,6 +254,30 @@ class TemplateManager implements ITemplateManager { return $templates; } + /* + * @return list<Field> + */ + private function getTemplateFields(TemplateFileCreator $type, int $fileId): array { + $providerTemplates = $this->getProviderTemplates($type); + $userTemplates = $this->getUserTemplates($type); + + $matchedTemplates = array_filter( + array_merge($providerTemplates, $userTemplates), + function (Template $template) use ($fileId) { + return $template->jsonSerialize()['fileid'] === $fileId; + }); + + if (empty($matchedTemplates)) { + return []; + } + + $this->eventDispatcher->dispatchTyped(new BeforeGetTemplatesEvent($matchedTemplates, true)); + + return array_values(array_map(function (Template $template) { + return $template->jsonSerialize()['fields'] ?? []; + }, $matchedTemplates)); + } + /** * @param Node|File $file * @return array @@ -240,7 +294,8 @@ class TemplateManager implements ITemplateManager { 'mime' => $file->getMimetype(), 'size' => $file->getSize(), 'type' => $file->getType(), - 'hasPreview' => $this->previewManager->isAvailable($file) + 'hasPreview' => $this->previewManager->isAvailable($file), + 'permissions' => $file->getPermissions(), ]; } @@ -261,19 +316,24 @@ class TemplateManager implements ITemplateManager { return $this->config->getUserValue($this->userId, 'core', 'templateDirectory', ''); } - public function initializeTemplateDirectory(string $path = null, string $userId = null, $copyTemplates = true): string { + public function initializeTemplateDirectory(?string $path = null, ?string $userId = null, $copyTemplates = true): string { if ($userId !== null) { $this->userId = $userId; } $defaultSkeletonDirectory = \OC::$SERVERROOT . '/core/skeleton'; $defaultTemplateDirectory = \OC::$SERVERROOT . '/core/skeleton/Templates'; - $skeletonPath = $this->config->getSystemValue('skeletondirectory', $defaultSkeletonDirectory); - $skeletonTemplatePath = $this->config->getSystemValue('templatedirectory', $defaultTemplateDirectory); + $skeletonPath = $this->config->getSystemValueString('skeletondirectory', $defaultSkeletonDirectory); + $skeletonTemplatePath = $this->config->getSystemValueString('templatedirectory', $defaultTemplateDirectory); $isDefaultSkeleton = $skeletonPath === $defaultSkeletonDirectory; $isDefaultTemplates = $skeletonTemplatePath === $defaultTemplateDirectory; $userLang = $this->l10nFactory->getUserLanguage($this->userManager->get($this->userId)); + if ($skeletonTemplatePath === '') { + $this->setTemplatePath(''); + return ''; + } + try { $l10n = $this->l10nFactory->get('lib', $userLang); $userFolder = $this->rootFolder->getUserFolder($this->userId); diff --git a/lib/private/Files/Type/Detection.php b/lib/private/Files/Type/Detection.php index 432bc4c4d6d..6af6ce1a0b1 100644 --- a/lib/private/Files/Type/Detection.php +++ b/lib/private/Files/Type/Detection.php @@ -1,47 +1,16 @@ <?php declare(strict_types=1); - /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Andreas Fischer <bantu@owncloud.com> - * @author bladewing <lukas@ifflaender-family.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Hendrik Leppelsack <hendrik@leppelsack.de> - * @author Jens-Christian Fischer <jens-christian.fischer@switch.ch> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author lui87kw <lukas.ifflaender@uni-wuerzburg.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Magnus Walbeck <mw@mwalbeck.org> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Tanghus <thomas@tanghus.net> - * @author Vincent Petry <vincent@nextcloud.com> - * @author Xheni Myrtaj <myrtajxheni@gmail.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Type; use OCP\Files\IMimeTypeDetector; +use OCP\IBinaryFinder; +use OCP\ITempManager; use OCP\IURLGenerator; use Psr\Log\LoggerInterface; @@ -55,70 +24,68 @@ use Psr\Log\LoggerInterface; class Detection implements IMimeTypeDetector { private const CUSTOM_MIMETYPEMAPPING = 'mimetypemapping.json'; private const CUSTOM_MIMETYPEALIASES = 'mimetypealiases.json'; - - protected $mimetypes = []; - protected $secureMimeTypes = []; - - protected $mimetypeIcons = []; - /** @var string[] */ - protected $mimeTypeAlias = []; - - /** @var IURLGenerator */ - private $urlGenerator; - - private LoggerInterface $logger; - - /** @var string */ - private $customConfigDir; - - /** @var string */ - private $defaultConfigDir; - - public function __construct(IURLGenerator $urlGenerator, - LoggerInterface $logger, - string $customConfigDir, - string $defaultConfigDir) { - $this->urlGenerator = $urlGenerator; - $this->logger = $logger; - $this->customConfigDir = $customConfigDir; - $this->defaultConfigDir = $defaultConfigDir; + private const CUSTOM_MIMETYPENAMES = 'mimetypenames.json'; + + /** @var array<list{string, string|null}> */ + protected array $mimeTypes = []; + protected array $secureMimeTypes = []; + + protected array $mimeTypeIcons = []; + /** @var array<string,string> */ + protected array $mimeTypeAlias = []; + /** @var array<string,string> */ + protected array $mimeTypesNames = []; + + public function __construct( + private IURLGenerator $urlGenerator, + private LoggerInterface $logger, + private string $customConfigDir, + private string $defaultConfigDir, + ) { } /** - * Add an extension -> mimetype mapping + * Add an extension -> MIME type mapping * - * $mimetype is the assumed correct mime type + * $mimeType is the assumed correct mime type * The optional $secureMimeType is an alternative to send to send * to avoid potential XSS. * * @param string $extension - * @param string $mimetype + * @param string $mimeType * @param string|null $secureMimeType */ - public function registerType(string $extension, - string $mimetype, - ?string $secureMimeType = null): void { - $this->mimetypes[$extension] = [$mimetype, $secureMimeType]; - $this->secureMimeTypes[$mimetype] = $secureMimeType ?: $mimetype; + public function registerType( + string $extension, + string $mimeType, + ?string $secureMimeType = null): void { + // Make sure the extension is a string + // https://github.com/nextcloud/server/issues/42902 + $this->mimeTypes[$extension] = [$mimeType, $secureMimeType]; + $this->secureMimeTypes[$mimeType] = $secureMimeType ?? $mimeType; } /** - * Add an array of extension -> mimetype mappings + * Add an array of extension -> MIME type mappings * - * The mimetype value is in itself an array where the first index is - * the assumed correct mimetype and the second is either a secure alternative + * The mimeType value is in itself an array where the first index is + * the assumed correct mimeType and the second is either a secure alternative * or null if the correct is considered secure. * * @param array $types */ public function registerTypeArray(array $types): void { - $this->mimetypes = array_merge($this->mimetypes, $types); + // Register the types, + foreach ($types as $extension => $mimeType) { + $this->registerType((string)$extension, $mimeType[0], $mimeType[1] ?? null); + } - // Update the alternative mimetypes to avoid having to look them up each time. - foreach ($this->mimetypes as $extension => $mimeType) { - if (strpos($extension, '_comment') === 0) { + // Update the alternative mimeTypes to avoid having to look them up each time. + foreach ($this->mimeTypes as $extension => $mimeType) { + if (str_starts_with((string)$extension, '_comment')) { continue; } + $this->secureMimeTypes[$mimeType[0]] = $mimeType[1] ?? $mimeType[0]; if (isset($mimeType[1])) { $this->secureMimeTypes[$mimeType[1]] = $mimeType[1]; @@ -139,7 +106,7 @@ class Detection implements IMimeTypeDetector { } /** - * Add the mimetype aliases if they are not yet present + * Add the MIME type aliases if they are not yet present */ private function loadAliases(): void { if (!empty($this->mimeTypeAlias)) { @@ -151,7 +118,7 @@ class Detection implements IMimeTypeDetector { } /** - * @return string[] + * @return array<string,string> */ public function getAllAliases(): array { $this->loadAliases(); @@ -165,29 +132,48 @@ class Detection implements IMimeTypeDetector { } /** - * Add mimetype mappings if they are not yet present + * Add MIME type mappings if they are not yet present */ private function loadMappings(): void { - if (!empty($this->mimetypes)) { + if (!empty($this->mimeTypes)) { return; } - $mimetypeMapping = json_decode(file_get_contents($this->defaultConfigDir . '/mimetypemapping.dist.json'), true); - $mimetypeMapping = $this->loadCustomDefinitions(self::CUSTOM_MIMETYPEMAPPING, $mimetypeMapping); + $mimeTypeMapping = json_decode(file_get_contents($this->defaultConfigDir . '/mimetypemapping.dist.json'), true); + $mimeTypeMapping = $this->loadCustomDefinitions(self::CUSTOM_MIMETYPEMAPPING, $mimeTypeMapping); - $this->registerTypeArray($mimetypeMapping); + $this->registerTypeArray($mimeTypeMapping); } /** - * @return array + * @return array<list{string, string|null}> */ public function getAllMappings(): array { $this->loadMappings(); - return $this->mimetypes; + return $this->mimeTypes; + } + + private function loadNamings(): void { + if (!empty($this->mimeTypesNames)) { + return; + } + + $mimeTypeMapping = json_decode(file_get_contents($this->defaultConfigDir . '/mimetypenames.dist.json'), true); + $mimeTypeMapping = $this->loadCustomDefinitions(self::CUSTOM_MIMETYPENAMES, $mimeTypeMapping); + + $this->mimeTypesNames = $mimeTypeMapping; } /** - * detect mimetype only based on filename, content of file is not used + * @return array<string,string> + */ + public function getAllNamings(): array { + $this->loadNamings(); + return $this->mimeTypesNames; + } + + /** + * detect MIME type only based on filename, content of file is not used * * @param string $path * @return string @@ -209,8 +195,8 @@ class Detection implements IMimeTypeDetector { $extension = strrchr($fileName, '.'); if ($extension !== false) { $extension = strtolower($extension); - $extension = substr($extension, 1); //remove leading . - return $this->mimetypes[$extension][0] ?? 'application/octet-stream'; + $extension = substr($extension, 1); // remove leading . + return $this->mimeTypes[$extension][0] ?? 'application/octet-stream'; } } @@ -218,7 +204,8 @@ class Detection implements IMimeTypeDetector { } /** - * detect mimetype only based on the content of file + * Detect MIME type only based on the content of file. + * * @param string $path * @return string * @since 18.0.0 @@ -231,14 +218,10 @@ class Detection implements IMimeTypeDetector { return 'httpd/unix-directory'; } - if (function_exists('finfo_open') - && function_exists('finfo_file') - && $finfo = finfo_open(FILEINFO_MIME)) { - $info = @finfo_file($finfo, $path); - finfo_close($finfo); - if ($info) { - $info = strtolower($info); - $mimeType = strpos($info, ';') !== false ? substr($info, 0, strpos($info, ';')) : $info; + if (class_exists(finfo::class)) { + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mimeType = @$finfo->file($path); + if ($mimeType) { $mimeType = $this->getSecureMimeType($mimeType); if ($mimeType !== 'application/octet-stream') { return $mimeType; @@ -246,7 +229,7 @@ class Detection implements IMimeTypeDetector { } } - if (strpos($path, '://') !== false && strpos($path, 'file://') === 0) { + if (str_starts_with($path, 'file://')) { // Is the file wrapped in a stream? return 'application/octet-stream'; } @@ -254,7 +237,7 @@ class Detection implements IMimeTypeDetector { if (function_exists('mime_content_type')) { // use mime magic extension if available $mimeType = mime_content_type($path); - if ($mimeType !== false) { + if ($mimeType) { $mimeType = $this->getSecureMimeType($mimeType); if ($mimeType !== 'application/octet-stream') { return $mimeType; @@ -262,28 +245,30 @@ class Detection implements IMimeTypeDetector { } } - if (\OC_Helper::canExecute('file')) { + $binaryFinder = \OCP\Server::get(IBinaryFinder::class); + $program = $binaryFinder->findBinaryPath('file'); + if ($program !== false) { // it looks like we have a 'file' command, // lets see if it does have mime support $path = escapeshellarg($path); - $fp = popen("test -f $path && file -b --mime-type $path", 'r'); - $mimeType = fgets($fp); - pclose($fp); - - if ($mimeType !== false) { - //trim the newline - $mimeType = trim($mimeType); - $mimeType = $this->getSecureMimeType($mimeType); - if ($mimeType !== 'application/octet-stream') { + $fp = popen("test -f $path && $program -b --mime-type $path", 'r'); + if ($fp !== false) { + $mimeType = fgets($fp); + pclose($fp); + if ($mimeType) { + //trim the newline + $mimeType = trim($mimeType); + $mimeType = $this->getSecureMimeType($mimeType); return $mimeType; } } } + return 'application/octet-stream'; } /** - * detect mimetype based on both filename and content + * Detect MIME type based on both filename and content * * @param string $path * @return string @@ -299,29 +284,31 @@ class Detection implements IMimeTypeDetector { } /** - * detect mimetype based on the content of a string + * Detect MIME type based on the content of a string * * @param string $data * @return string */ public function detectString($data): string { - if (function_exists('finfo_open') && function_exists('finfo_file')) { - $finfo = finfo_open(FILEINFO_MIME); - $info = finfo_buffer($finfo, $data); - return strpos($info, ';') !== false ? substr($info, 0, strpos($info, ';')) : $info; + if (class_exists(finfo::class)) { + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->buffer($data); + if ($mimeType) { + return $mimeType; + } } - $tmpFile = \OC::$server->getTempManager()->getTemporaryFile(); + $tmpFile = \OCP\Server::get(ITempManager::class)->getTemporaryFile(); $fh = fopen($tmpFile, 'wb'); fwrite($fh, $data, 8024); fclose($fh); - $mime = $this->detect($tmpFile); + $mimeType = $this->detect($tmpFile); unset($tmpFile); - return $mime; + return $mimeType; } /** - * Get a secure mimetype that won't expose potential XSS. + * Get a secure MIME type that won't expose potential XSS. * * @param string $mimeType * @return string @@ -334,57 +321,56 @@ class Detection implements IMimeTypeDetector { /** * Get path to the icon of a file type - * @param string $mimetype the MIME type + * @param string $mimeType the MIME type * @return string the url */ - public function mimeTypeIcon($mimetype): string { + public function mimeTypeIcon($mimeType): string { $this->loadAliases(); - while (isset($this->mimeTypeAlias[$mimetype])) { - $mimetype = $this->mimeTypeAlias[$mimetype]; + while (isset($this->mimeTypeAlias[$mimeType])) { + $mimeType = $this->mimeTypeAlias[$mimeType]; } - if (isset($this->mimetypeIcons[$mimetype])) { - return $this->mimetypeIcons[$mimetype]; + if (isset($this->mimeTypeIcons[$mimeType])) { + return $this->mimeTypeIcons[$mimeType]; } // Replace slash and backslash with a minus - $icon = str_replace(['/', '\\'], '-', $mimetype); + $icon = str_replace(['/', '\\'], '-', $mimeType); // Is it a dir? - if ($mimetype === 'dir') { - $this->mimetypeIcons[$mimetype] = $this->urlGenerator->imagePath('core', 'filetypes/folder.svg'); - return $this->mimetypeIcons[$mimetype]; + if ($mimeType === 'dir') { + $this->mimeTypeIcons[$mimeType] = $this->urlGenerator->imagePath('core', 'filetypes/folder.svg'); + return $this->mimeTypeIcons[$mimeType]; } - if ($mimetype === 'dir-shared') { - $this->mimetypeIcons[$mimetype] = $this->urlGenerator->imagePath('core', 'filetypes/folder-shared.svg'); - return $this->mimetypeIcons[$mimetype]; + if ($mimeType === 'dir-shared') { + $this->mimeTypeIcons[$mimeType] = $this->urlGenerator->imagePath('core', 'filetypes/folder-shared.svg'); + return $this->mimeTypeIcons[$mimeType]; } - if ($mimetype === 'dir-external') { - $this->mimetypeIcons[$mimetype] = $this->urlGenerator->imagePath('core', 'filetypes/folder-external.svg'); - return $this->mimetypeIcons[$mimetype]; + if ($mimeType === 'dir-external') { + $this->mimeTypeIcons[$mimeType] = $this->urlGenerator->imagePath('core', 'filetypes/folder-external.svg'); + return $this->mimeTypeIcons[$mimeType]; } // Icon exists? try { - $this->mimetypeIcons[$mimetype] = $this->urlGenerator->imagePath('core', 'filetypes/' . $icon . '.svg'); - return $this->mimetypeIcons[$mimetype]; + $this->mimeTypeIcons[$mimeType] = $this->urlGenerator->imagePath('core', 'filetypes/' . $icon . '.svg'); + return $this->mimeTypeIcons[$mimeType]; } catch (\RuntimeException $e) { // Specified image not found } // Try only the first part of the filetype - if (strpos($icon, '-')) { $mimePart = substr($icon, 0, strpos($icon, '-')); try { - $this->mimetypeIcons[$mimetype] = $this->urlGenerator->imagePath('core', 'filetypes/' . $mimePart . '.svg'); - return $this->mimetypeIcons[$mimetype]; + $this->mimeTypeIcons[$mimeType] = $this->urlGenerator->imagePath('core', 'filetypes/' . $mimePart . '.svg'); + return $this->mimeTypeIcons[$mimeType]; } catch (\RuntimeException $e) { - // Image for the first part of the mimetype not found + // Image for the first part of the MIME type not found } } - $this->mimetypeIcons[$mimetype] = $this->urlGenerator->imagePath('core', 'filetypes/file.svg'); - return $this->mimetypeIcons[$mimetype]; + $this->mimeTypeIcons[$mimeType] = $this->urlGenerator->imagePath('core', 'filetypes/file.svg'); + return $this->mimeTypeIcons[$mimeType]; } } diff --git a/lib/private/Files/Type/Loader.php b/lib/private/Files/Type/Loader.php index bf5af36ec6e..5fbe4139759 100644 --- a/lib/private/Files/Type/Loader.php +++ b/lib/private/Files/Type/Loader.php @@ -1,29 +1,15 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Joas Schilling <coding@schilljs.com> - * @author Rello <Rello@users.noreply.github.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Type; +use OC\DB\Exceptions\DbalException; +use OCP\AppFramework\Db\TTransactional; +use OCP\DB\Exception as DBException; use OCP\Files\IMimeTypeLoader; use OCP\IDBConnection; @@ -33,31 +19,28 @@ use OCP\IDBConnection; * @package OC\Files\Type */ class Loader implements IMimeTypeLoader { - /** @var IDBConnection */ - private $dbConnection; + use TTransactional; - /** @var array [id => mimetype] */ - protected $mimetypes; + /** @psalm-var array<int, string> */ + protected array $mimetypes; - /** @var array [mimetype => id] */ - protected $mimetypeIds; + /** @psalm-var array<string, int> */ + protected array $mimetypeIds; /** * @param IDBConnection $dbConnection */ - public function __construct(IDBConnection $dbConnection) { - $this->dbConnection = $dbConnection; + public function __construct( + private IDBConnection $dbConnection, + ) { $this->mimetypes = []; $this->mimetypeIds = []; } /** * Get a mimetype from its ID - * - * @param int $id - * @return string|null */ - public function getMimetypeById($id) { + public function getMimetypeById(int $id): ?string { if (!$this->mimetypes) { $this->loadMimetypes(); } @@ -69,11 +52,8 @@ class Loader implements IMimeTypeLoader { /** * Get a mimetype ID, adding the mimetype to the DB if it does not exist - * - * @param string $mimetype - * @return int */ - public function getId($mimetype) { + public function getId(string $mimetype): int { if (!$this->mimetypeIds) { $this->loadMimetypes(); } @@ -85,11 +65,8 @@ class Loader implements IMimeTypeLoader { /** * Test if a mimetype exists in the database - * - * @param string $mimetype - * @return bool */ - public function exists($mimetype) { + public function exists(string $mimetype): bool { if (!$this->mimetypeIds) { $this->loadMimetypes(); } @@ -99,7 +76,7 @@ class Loader implements IMimeTypeLoader { /** * Clear all loaded mimetypes, allow for re-loading */ - public function reset() { + public function reset(): void { $this->mimetypes = []; $this->mimetypeIds = []; } @@ -108,59 +85,67 @@ class Loader implements IMimeTypeLoader { * Store a mimetype in the DB * * @param string $mimetype - * @param int inserted ID + * @return int inserted ID */ - protected function store($mimetype) { - $this->dbConnection->insertIfNotExist('*PREFIX*mimetypes', [ - 'mimetype' => $mimetype - ]); - - $fetch = $this->dbConnection->getQueryBuilder(); - $fetch->select('id') - ->from('mimetypes') - ->where( - $fetch->expr()->eq('mimetype', $fetch->createNamedParameter($mimetype) - )); - - $result = $fetch->execute(); - $row = $result->fetch(); - $result->closeCursor(); - - if (!$row) { - throw new \Exception("Failed to get mimetype id for $mimetype after trying to store it"); + protected function store(string $mimetype): int { + try { + $mimetypeId = $this->atomic(function () use ($mimetype) { + $insert = $this->dbConnection->getQueryBuilder(); + $insert->insert('mimetypes') + ->values([ + 'mimetype' => $insert->createNamedParameter($mimetype) + ]) + ->executeStatement(); + return $insert->getLastInsertId(); + }, $this->dbConnection); + } catch (DbalException $e) { + if ($e->getReason() !== DBException::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $e; + } + + $qb = $this->dbConnection->getQueryBuilder(); + $qb->select('id') + ->from('mimetypes') + ->where($qb->expr()->eq('mimetype', $qb->createNamedParameter($mimetype))); + $result = $qb->executeQuery(); + $id = $result->fetchOne(); + $result->closeCursor(); + if ($id === false) { + throw new \Exception("Database threw an unique constraint on inserting a new mimetype, but couldn't return the ID for this very mimetype"); + } + + $mimetypeId = (int)$id; } - $this->mimetypes[$row['id']] = $mimetype; - $this->mimetypeIds[$mimetype] = $row['id']; - return $row['id']; + $this->mimetypes[$mimetypeId] = $mimetype; + $this->mimetypeIds[$mimetype] = $mimetypeId; + return $mimetypeId; } /** * Load all mimetypes from DB */ - private function loadMimetypes() { + private function loadMimetypes(): void { $qb = $this->dbConnection->getQueryBuilder(); $qb->select('id', 'mimetype') ->from('mimetypes'); - $result = $qb->execute(); + $result = $qb->executeQuery(); $results = $result->fetchAll(); $result->closeCursor(); foreach ($results as $row) { - $this->mimetypes[$row['id']] = $row['mimetype']; - $this->mimetypeIds[$row['mimetype']] = $row['id']; + $this->mimetypes[(int)$row['id']] = $row['mimetype']; + $this->mimetypeIds[$row['mimetype']] = (int)$row['id']; } } /** * Update filecache mimetype based on file extension * - * @param string $ext file extension - * @param int $mimeTypeId * @return int number of changed rows */ - public function updateFilecache($ext, $mimeTypeId) { + public function updateFilecache(string $ext, int $mimeTypeId): int { $folderMimeTypeId = $this->getId('httpd/unix-directory'); $update = $this->dbConnection->getQueryBuilder(); $update->update('filecache') @@ -175,6 +160,6 @@ class Loader implements IMimeTypeLoader { $update->func()->lower('name'), $update->createNamedParameter('%' . $this->dbConnection->escapeLikeParameter('.' . $ext)) )); - return $update->execute(); + return $update->executeStatement(); } } diff --git a/lib/private/Files/Type/TemplateManager.php b/lib/private/Files/Type/TemplateManager.php index 6210edaaf05..a0c0e9fac9f 100644 --- a/lib/private/Files/Type/TemplateManager.php +++ b/lib/private/Files/Type/TemplateManager.php @@ -1,27 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Type; diff --git a/lib/private/Files/Utils/PathHelper.php b/lib/private/Files/Utils/PathHelper.php index 07985e884ce..db1294bcc10 100644 --- a/lib/private/Files/Utils/PathHelper.php +++ b/lib/private/Files/Utils/PathHelper.php @@ -2,25 +2,9 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2022 Robin Appelman <robin@icewind.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ - namespace OC\Files\Utils; class PathHelper { @@ -37,7 +21,7 @@ class PathHelper { } if ($path === $root) { return '/'; - } elseif (strpos($path, $root . '/') !== 0) { + } elseif (!str_starts_with($path, $root . '/')) { return null; } else { $path = substr($path, strlen($root)); @@ -53,6 +37,8 @@ class PathHelper { if ($path === '' or $path === '/') { return '/'; } + // No null bytes + $path = str_replace(chr(0), '', $path); //no windows style slashes $path = str_replace('\\', '/', $path); //add leading slash @@ -60,7 +46,7 @@ class PathHelper { $path = '/' . $path; } //remove duplicate slashes - while (strpos($path, '//') !== false) { + while (str_contains($path, '//')) { $path = str_replace('//', '/', $path); } //remove trailing slash diff --git a/lib/private/Files/Utils/Scanner.php b/lib/private/Files/Utils/Scanner.php index dc220bc710d..576cb66b3cf 100644 --- a/lib/private/Files/Utils/Scanner.php +++ b/lib/private/Files/Utils/Scanner.php @@ -1,37 +1,16 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Blaok <i@blaok.me> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Olivier Paroz <github@oparoz.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files\Utils; use OC\Files\Cache\Cache; use OC\Files\Filesystem; use OC\Files\Storage\FailedStorage; +use OC\Files\Storage\Home; use OC\ForbiddenException; use OC\Hooks\PublicEmitter; use OC\Lock\DBLockingProvider; @@ -39,15 +18,18 @@ use OCA\Files_Sharing\SharedStorage; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\Events\BeforeFileScannedEvent; use OCP\Files\Events\BeforeFolderScannedEvent; -use OCP\Files\Events\NodeAddedToCache; use OCP\Files\Events\FileCacheUpdated; -use OCP\Files\Events\NodeRemovedFromCache; use OCP\Files\Events\FileScannedEvent; use OCP\Files\Events\FolderScannedEvent; +use OCP\Files\Events\NodeAddedToCache; +use OCP\Files\Events\NodeRemovedFromCache; +use OCP\Files\Mount\IMountPoint; use OCP\Files\NotFoundException; use OCP\Files\Storage\IStorage; use OCP\Files\StorageNotAvailableException; use OCP\IDBConnection; +use OCP\Lock\ILockingProvider; +use OCP\Lock\LockedException; use Psr\Log\LoggerInterface; /** @@ -98,14 +80,14 @@ class Scanner extends PublicEmitter { $this->dispatcher = $dispatcher; $this->logger = $logger; // when DB locking is used, no DB transactions will be used - $this->useTransaction = !(\OC::$server->getLockingProvider() instanceof DBLockingProvider); + $this->useTransaction = !(\OC::$server->get(ILockingProvider::class) instanceof DBLockingProvider); } /** * get all storages for $dir * * @param string $dir - * @return \OC\Files\Mount\MountPoint[] + * @return array<string, IMountPoint> */ protected function getMounts($dir) { //TODO: move to the node based fileapi once that's done @@ -116,8 +98,9 @@ class Scanner extends PublicEmitter { $mounts = $mountManager->findIn($dir); $mounts[] = $mountManager->find($dir); $mounts = array_reverse($mounts); //start with the mount of $dir + $mountPoints = array_map(fn ($mount) => $mount->getMountPoint(), $mounts); - return $mounts; + return array_combine($mountPoints, $mounts); } /** @@ -126,6 +109,7 @@ class Scanner extends PublicEmitter { * @param \OC\Files\Mount\MountPoint $mount */ protected function attachListener($mount) { + /** @var \OC\Files\Cache\Scanner $scanner */ $scanner = $mount->getStorage()->getScanner(); $scanner->listen('\OC\Files\Cache\Scanner', 'scanFile', function ($path) use ($mount) { $this->emit('\OC\Files\Utils\Scanner', 'scanFile', [$mount->getMountPoint() . $path]); @@ -154,33 +138,38 @@ class Scanner extends PublicEmitter { public function backgroundScan($dir) { $mounts = $this->getMounts($dir); foreach ($mounts as $mount) { - $storage = $mount->getStorage(); - if (is_null($storage)) { - continue; - } + try { + $storage = $mount->getStorage(); + if (is_null($storage)) { + continue; + } - // don't bother scanning failed storages (shortcut for same result) - if ($storage->instanceOfStorage(FailedStorage::class)) { - continue; - } + // don't bother scanning failed storages (shortcut for same result) + if ($storage->instanceOfStorage(FailedStorage::class)) { + continue; + } - $scanner = $storage->getScanner(); - $this->attachListener($mount); + /** @var \OC\Files\Cache\Scanner $scanner */ + $scanner = $storage->getScanner(); + $this->attachListener($mount); - $scanner->listen('\OC\Files\Cache\Scanner', 'removeFromCache', function ($path) use ($storage) { - $this->triggerPropagator($storage, $path); - }); - $scanner->listen('\OC\Files\Cache\Scanner', 'updateCache', function ($path) use ($storage) { - $this->triggerPropagator($storage, $path); - }); - $scanner->listen('\OC\Files\Cache\Scanner', 'addToCache', function ($path) use ($storage) { - $this->triggerPropagator($storage, $path); - }); + $scanner->listen('\OC\Files\Cache\Scanner', 'removeFromCache', function ($path) use ($storage) { + $this->triggerPropagator($storage, $path); + }); + $scanner->listen('\OC\Files\Cache\Scanner', 'updateCache', function ($path) use ($storage) { + $this->triggerPropagator($storage, $path); + }); + $scanner->listen('\OC\Files\Cache\Scanner', 'addToCache', function ($path) use ($storage) { + $this->triggerPropagator($storage, $path); + }); - $propagator = $storage->getPropagator(); - $propagator->beginBatch(); - $scanner->backgroundScan(); - $propagator->commitBatch(); + $propagator = $storage->getPropagator(); + $propagator->beginBatch(); + $scanner->backgroundScan(); + $propagator->commitBatch(); + } catch (\Exception $e) { + $this->logger->error("Error while trying to scan mount as {$mount->getMountPoint()}:" . $e->getMessage(), ['exception' => $e, 'app' => 'files']); + } } } @@ -191,7 +180,7 @@ class Scanner extends PublicEmitter { * @throws ForbiddenException * @throws NotFoundException */ - public function scan($dir = '', $recursive = \OC\Files\Cache\Scanner::SCAN_RECURSIVE, callable $mountFilter = null) { + public function scan($dir = '', $recursive = \OC\Files\Cache\Scanner::SCAN_RECURSIVE, ?callable $mountFilter = null) { if (!Filesystem::isValidPath($dir)) { throw new \InvalidArgumentException('Invalid path to scan'); } @@ -211,13 +200,27 @@ class Scanner extends PublicEmitter { } // if the home storage isn't writable then the scanner is run as the wrong user - if ($storage->instanceOfStorage('\OC\Files\Storage\Home') and - (!$storage->isCreatable('') or !$storage->isCreatable('files')) - ) { - if ($storage->is_dir('files')) { - throw new ForbiddenException(); - } else {// if the root exists in neither the cache nor the storage the user isn't setup yet - break; + if ($storage->instanceOfStorage(Home::class)) { + /** @var Home $storage */ + foreach (['', 'files'] as $path) { + if (!$storage->isCreatable($path)) { + $fullPath = $storage->getSourcePath($path); + if (isset($mounts[$mount->getMountPoint() . $path . '/'])) { + // /<user>/files is overwritten by a mountpoint, so this check is irrelevant + break; + } elseif (!$storage->is_dir($path) && $storage->getCache()->inCache($path)) { + throw new NotFoundException("User folder $fullPath exists in cache but not on disk"); + } elseif ($storage->is_dir($path)) { + $ownerUid = fileowner($fullPath); + $owner = posix_getpwuid($ownerUid); + $owner = $owner['name'] ?? $ownerUid; + $permissions = decoct(fileperms($fullPath)); + throw new ForbiddenException("User folder $fullPath is not writable, folders is owned by $owner and has mode $permissions"); + } else { + // if the root exists in neither the cache nor the storage the user isn't setup yet + break 2; + } + } } } @@ -226,6 +229,7 @@ class Scanner extends PublicEmitter { continue; } $relativePath = $mount->getInternalPath($dir); + /** @var \OC\Files\Cache\Scanner $scanner */ $scanner = $storage->getScanner(); $scanner->setUseTransactions(false); $this->attachListener($mount); @@ -238,9 +242,13 @@ class Scanner extends PublicEmitter { $this->postProcessEntry($storage, $path); $this->dispatcher->dispatchTyped(new FileCacheUpdated($storage, $path)); }); - $scanner->listen('\OC\Files\Cache\Scanner', 'addToCache', function ($path) use ($storage) { + $scanner->listen('\OC\Files\Cache\Scanner', 'addToCache', function ($path, $storageId, $data, $fileId) use ($storage) { $this->postProcessEntry($storage, $path); - $this->dispatcher->dispatchTyped(new NodeAddedToCache($storage, $path)); + if ($fileId) { + $this->dispatcher->dispatchTyped(new FileCacheUpdated($storage, $path)); + } else { + $this->dispatcher->dispatchTyped(new NodeAddedToCache($storage, $path)); + } }); if (!$storage->file_exists($relativePath)) { @@ -253,7 +261,15 @@ class Scanner extends PublicEmitter { try { $propagator = $storage->getPropagator(); $propagator->beginBatch(); - $scanner->scan($relativePath, $recursive, \OC\Files\Cache\Scanner::REUSE_ETAG | \OC\Files\Cache\Scanner::REUSE_SIZE); + try { + $scanner->scan($relativePath, $recursive, \OC\Files\Cache\Scanner::REUSE_ETAG | \OC\Files\Cache\Scanner::REUSE_SIZE); + } catch (LockedException $e) { + if (is_string($e->getReadablePath()) && str_starts_with($e->getReadablePath(), 'scanner::')) { + throw new LockedException("scanner::$dir", $e, $e->getExistingLock()); + } else { + throw $e; + } + } $cache = $storage->getCache(); if ($cache instanceof Cache) { // only re-calculate for the root folder we scanned, anything below that is taken care of by the scanner diff --git a/lib/private/Files/View.php b/lib/private/Files/View.php index 8f073da9164..a852f453963 100644 --- a/lib/private/Files/View.php +++ b/lib/private/Files/View.php @@ -1,72 +1,43 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Ashod Nakashian <ashod.nakashian@collabora.co.uk> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Florin Peter <github@florin-peter.de> - * @author Jesús Macias <jmacias@solidgear.es> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Julius Härtl <jus@bitgrid.net> - * @author karakayasemi <karakayasemi@itu.edu.tr> - * @author Klaas Freitag <freitag@owncloud.com> - * @author korelstar <korelstar@users.noreply.github.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Luke Policinski <lpolicinski@gmail.com> - * @author Michael Gapczynski <GapczynskiM@gmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Piotr Filiciak <piotr@filiciak.pl> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Sam Tuke <mail@samtuke.com> - * @author Scott Dutton <exussum12@users.noreply.github.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Thomas Tanghus <thomas@tanghus.net> - * @author Vincent Petry <vincent@nextcloud.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OC\Files; use Icewind\Streams\CallbackWrapper; use OC\Files\Mount\MoveableMount; use OC\Files\Storage\Storage; -use OC\User\LazyUser; +use OC\Files\Storage\Wrapper\Quota; use OC\Share\Share; +use OC\User\LazyUser; +use OC\User\Manager as UserManager; use OC\User\User; use OCA\Files_Sharing\SharedMount; use OCP\Constants; +use OCP\Files; use OCP\Files\Cache\ICacheEntry; +use OCP\Files\ConnectionLostException; use OCP\Files\EmptyFileNameException; use OCP\Files\FileNameTooLongException; +use OCP\Files\ForbiddenException; use OCP\Files\InvalidCharacterInPathException; use OCP\Files\InvalidDirectoryException; use OCP\Files\InvalidPathException; +use OCP\Files\Mount\IMountManager; use OCP\Files\Mount\IMountPoint; use OCP\Files\NotFoundException; use OCP\Files\ReservedWordException; -use OCP\Files\Storage\IStorage; use OCP\IUser; +use OCP\IUserManager; +use OCP\L10N\IFactory; use OCP\Lock\ILockingProvider; use OCP\Lock\LockedException; +use OCP\Server; +use OCP\Share\IManager; +use OCP\Share\IShare; use Psr\Log\LoggerInterface; /** @@ -86,43 +57,35 @@ use Psr\Log\LoggerInterface; * \OC\Files\Storage\Storage object */ class View { - /** @var string */ - private $fakeRoot = ''; - - /** - * @var \OCP\Lock\ILockingProvider - */ - protected $lockingProvider; - - private $lockingEnabled; - - private $updaterEnabled = true; - - /** @var \OC\User\Manager */ - private $userManager; - + private string $fakeRoot = ''; + private ILockingProvider $lockingProvider; + private bool $lockingEnabled; + private bool $updaterEnabled = true; + private UserManager $userManager; private LoggerInterface $logger; /** - * @param string $root * @throws \Exception If $root contains an invalid path */ - public function __construct($root = '') { - if (is_null($root)) { - throw new \InvalidArgumentException('Root can\'t be null'); - } + public function __construct(string $root = '') { if (!Filesystem::isValidPath($root)) { throw new \Exception(); } $this->fakeRoot = $root; - $this->lockingProvider = \OC::$server->getLockingProvider(); + $this->lockingProvider = \OC::$server->get(ILockingProvider::class); $this->lockingEnabled = !($this->lockingProvider instanceof \OC\Lock\NoopLockingProvider); $this->userManager = \OC::$server->getUserManager(); $this->logger = \OC::$server->get(LoggerInterface::class); } - public function getAbsolutePath($path = '/') { + /** + * @param ?string $path + * @psalm-template S as string|null + * @psalm-param S $path + * @psalm-return (S is string ? string : null) + */ + public function getAbsolutePath($path = '/'): ?string { if ($path === null) { return null; } @@ -137,12 +100,11 @@ class View { } /** - * change the root to a fake root + * Change the root to a fake root * * @param string $fakeRoot - * @return boolean|null */ - public function chroot($fakeRoot) { + public function chroot($fakeRoot): void { if (!$fakeRoot == '') { if ($fakeRoot[0] !== '/') { $fakeRoot = '/' . $fakeRoot; @@ -152,11 +114,9 @@ class View { } /** - * get the fake root - * - * @return string + * Get the fake root */ - public function getRoot() { + public function getRoot(): string { return $this->fakeRoot; } @@ -164,9 +124,8 @@ class View { * get path relative to the root of the view * * @param string $path - * @return string */ - public function getRelativePath($path) { + public function getRelativePath($path): ?string { $this->assertPathLength($path); if ($this->fakeRoot == '') { return $path; @@ -179,7 +138,7 @@ class View { // missing slashes can cause wrong matches! $root = rtrim($this->fakeRoot, '/') . '/'; - if (strpos($path, $root) !== 0) { + if (!str_starts_with($path, $root)) { return null; } else { $path = substr($path, strlen($this->fakeRoot)); @@ -192,74 +151,56 @@ class View { } /** - * get the mountpoint of the storage object for a path + * Get the mountpoint of the storage object for a path * ( note: because a storage is not always mounted inside the fakeroot, the * returned mountpoint is relative to the absolute root of the filesystem * and does not take the chroot into account ) * * @param string $path - * @return string */ - public function getMountPoint($path) { + public function getMountPoint($path): string { return Filesystem::getMountPoint($this->getAbsolutePath($path)); } /** - * get the mountpoint of the storage object for a path + * Get the mountpoint of the storage object for a path * ( note: because a storage is not always mounted inside the fakeroot, the * returned mountpoint is relative to the absolute root of the filesystem * and does not take the chroot into account ) * * @param string $path - * @return \OCP\Files\Mount\IMountPoint */ - public function getMount($path) { + public function getMount($path): IMountPoint { return Filesystem::getMountManager()->find($this->getAbsolutePath($path)); } /** - * resolve a path to a storage and internal path + * Resolve a path to a storage and internal path * * @param string $path - * @return array an array consisting of the storage and the internal path + * @return array{?\OCP\Files\Storage\IStorage, string} an array consisting of the storage and the internal path */ - public function resolvePath($path) { + public function resolvePath($path): array { $a = $this->getAbsolutePath($path); $p = Filesystem::normalizePath($a); return Filesystem::resolvePath($p); } /** - * return the path to a local version of the file + * Return the path to a local version of the file * we need this because we can't know if a file is stored local or not from * outside the filestorage and for some purposes a local file is needed * * @param string $path - * @return string */ - public function getLocalFile($path) { - $parent = substr($path, 0, strrpos($path, '/')); + public function getLocalFile($path): string|false { + $parent = substr($path, 0, strrpos($path, '/') ?: 0); $path = $this->getAbsolutePath($path); [$storage, $internalPath] = Filesystem::resolvePath($path); - if (Filesystem::isValidPath($parent) and $storage) { + if (Filesystem::isValidPath($parent) && $storage) { return $storage->getLocalFile($internalPath); } else { - return null; - } - } - - /** - * @param string $path - * @return string - */ - public function getLocalFolder($path) { - $parent = substr($path, 0, strrpos($path, '/')); - $path = $this->getAbsolutePath($path); - [$storage, $internalPath] = Filesystem::resolvePath($path); - if (Filesystem::isValidPath($parent) and $storage) { - return $storage->getLocalFolder($internalPath); - } else { - return null; + return false; } } @@ -277,16 +218,15 @@ class View { * * @param IMountPoint $mount * @param string $path relative to data/ - * @return boolean */ - protected function removeMount($mount, $path) { + protected function removeMount($mount, $path): bool { if ($mount instanceof MoveableMount) { // cut of /user/files to get the relative path to data/user/files $pathParts = explode('/', $path, 4); $relPath = '/' . $pathParts[3]; $this->lockFile($relPath, ILockingProvider::LOCK_SHARED, true); \OC_Hook::emit( - Filesystem::CLASSNAME, "umount", + Filesystem::CLASSNAME, 'umount', [Filesystem::signal_param_path => $relPath] ); $this->changeLock($relPath, ILockingProvider::LOCK_EXCLUSIVE, true); @@ -294,7 +234,7 @@ class View { $this->changeLock($relPath, ILockingProvider::LOCK_SHARED, true); if ($result) { \OC_Hook::emit( - Filesystem::CLASSNAME, "post_umount", + Filesystem::CLASSNAME, 'post_umount', [Filesystem::signal_param_path => $relPath] ); } @@ -308,35 +248,41 @@ class View { } } - public function disableCacheUpdate() { + public function disableCacheUpdate(): void { $this->updaterEnabled = false; } - public function enableCacheUpdate() { + public function enableCacheUpdate(): void { $this->updaterEnabled = true; } - protected function writeUpdate(Storage $storage, $internalPath, $time = null) { + protected function writeUpdate(Storage $storage, string $internalPath, ?int $time = null, ?int $sizeDifference = null): void { if ($this->updaterEnabled) { if (is_null($time)) { $time = time(); } - $storage->getUpdater()->update($internalPath, $time); + $storage->getUpdater()->update($internalPath, $time, $sizeDifference); } } - protected function removeUpdate(Storage $storage, $internalPath) { + protected function removeUpdate(Storage $storage, string $internalPath): void { if ($this->updaterEnabled) { $storage->getUpdater()->remove($internalPath); } } - protected function renameUpdate(Storage $sourceStorage, Storage $targetStorage, $sourceInternalPath, $targetInternalPath) { + protected function renameUpdate(Storage $sourceStorage, Storage $targetStorage, string $sourceInternalPath, string $targetInternalPath): void { if ($this->updaterEnabled) { $targetStorage->getUpdater()->renameFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); } } + protected function copyUpdate(Storage $sourceStorage, Storage $targetStorage, string $sourceInternalPath, string $targetInternalPath): void { + if ($this->updaterEnabled) { + $targetStorage->getUpdater()->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + } + } + /** * @param string $path * @return bool|mixed @@ -363,7 +309,7 @@ class View { /** * @param string $path - * @return resource + * @return resource|false */ public function opendir($path) { return $this->basicOperation('opendir', $path, ['read']); @@ -411,14 +357,14 @@ class View { * @param string $path * @return mixed */ - public function filesize($path) { + public function filesize(string $path) { return $this->basicOperation('filesize', $path); } /** * @param string $path * @return bool|mixed - * @throws \OCP\Files\InvalidPathException + * @throws InvalidPathException */ public function readfile($path) { $this->assertPathLength($path); @@ -427,10 +373,11 @@ class View { } $handle = $this->fopen($path, 'rb'); if ($handle) { - $chunkSize = 524288; // 512 kB chunks + $chunkSize = 524288; // 512 kiB chunks while (!feof($handle)) { echo fread($handle, $chunkSize); flush(); + $this->checkConnectionStatus(); } fclose($handle); return $this->filesize($path); @@ -443,7 +390,7 @@ class View { * @param int $from * @param int $to * @return bool|mixed - * @throws \OCP\Files\InvalidPathException + * @throws InvalidPathException * @throws \OCP\Files\UnseekableException */ public function readfilePart($path, $from, $to) { @@ -453,7 +400,7 @@ class View { } $handle = $this->fopen($path, 'rb'); if ($handle) { - $chunkSize = 524288; // 512 kB chunks + $chunkSize = 524288; // 512 kiB chunks $startReading = true; if ($from !== 0 && $from !== '0' && fseek($handle, $from) !== 0) { @@ -483,6 +430,7 @@ class View { } echo fread($handle, $len); flush(); + $this->checkConnectionStatus(); } return ftell($handle) - $from; } @@ -492,6 +440,13 @@ class View { return false; } + private function checkConnectionStatus(): void { + $connectionStatus = \connection_status(); + if ($connectionStatus !== CONNECTION_NORMAL) { + throw new ConnectionLostException("Connection lost. Status: $connectionStatus"); + } + } + /** * @param string $path * @return mixed @@ -559,10 +514,9 @@ class View { /** * @param string $path * @param int|string $mtime - * @return bool */ - public function touch($path, $mtime = null) { - if (!is_null($mtime) and !is_numeric($mtime)) { + public function touch($path, $mtime = null): bool { + if (!is_null($mtime) && !is_numeric($mtime)) { $mtime = strtotime($mtime); } @@ -595,19 +549,14 @@ class View { /** * @param string $path - * @return mixed + * @return string|false * @throws LockedException */ public function file_get_contents($path) { return $this->basicOperation('file_get_contents', $path, ['read']); } - /** - * @param bool $exists - * @param string $path - * @param bool $run - */ - protected function emit_file_hooks_pre($exists, $path, &$run) { + protected function emit_file_hooks_pre(bool $exists, string $path, bool &$run): void { if (!$exists) { \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_create, [ Filesystem::signal_param_path => $this->getHookPath($path), @@ -625,11 +574,7 @@ class View { ]); } - /** - * @param bool $exists - * @param string $path - */ - protected function emit_file_hooks_post($exists, $path) { + protected function emit_file_hooks_post(bool $exists, string $path): void { if (!$exists) { \OC_Hook::emit(Filesystem::CLASSNAME, Filesystem::signal_post_create, [ Filesystem::signal_param_path => $this->getHookPath($path), @@ -654,20 +599,23 @@ class View { if (is_resource($data)) { //not having to deal with streams in file_put_contents makes life easier $absolutePath = Filesystem::normalizePath($this->getAbsolutePath($path)); if (Filesystem::isValidPath($path) - and !Filesystem::isFileBlacklisted($path) + && !Filesystem::isFileBlacklisted($path) ) { $path = $this->getRelativePath($absolutePath); + if ($path === null) { + throw new InvalidPathException("Path $absolutePath is not in the expected root"); + } $this->lockFile($path, ILockingProvider::LOCK_SHARED); $exists = $this->file_exists($path); - $run = true; if ($this->shouldEmitHooks($path)) { + $run = true; $this->emit_file_hooks_pre($exists, $path, $run); - } - if (!$run) { - $this->unlockFile($path, ILockingProvider::LOCK_SHARED); - return false; + if (!$run) { + $this->unlockFile($path, ILockingProvider::LOCK_SHARED); + return false; + } } try { @@ -678,11 +626,11 @@ class View { throw $e; } - /** @var \OC\Files\Storage\Storage $storage */ + /** @var Storage $storage */ [$storage, $internalPath] = $this->resolvePath($path); $target = $storage->fopen($internalPath, 'w'); if ($target) { - [, $result] = \OC_Helper::streamCopy($data, $target); + [, $result] = Files::streamCopy($data, $target, true); fclose($target); fclose($data); @@ -751,24 +699,43 @@ class View { * * @param string $source source path * @param string $target target path + * @param array $options * * @return bool|mixed * @throws LockedException */ - public function rename($source, $target) { + public function rename($source, $target, array $options = []) { + $checkSubMounts = $options['checkSubMounts'] ?? true; + $absolutePath1 = Filesystem::normalizePath($this->getAbsolutePath($source)); $absolutePath2 = Filesystem::normalizePath($this->getAbsolutePath($target)); + + if (str_starts_with($absolutePath2, $absolutePath1 . '/')) { + throw new ForbiddenException('Moving a folder into a child folder is forbidden', false); + } + + /** @var IMountManager $mountManager */ + $mountManager = \OC::$server->get(IMountManager::class); + + $targetParts = explode('/', $absolutePath2); + $targetUser = $targetParts[1] ?? null; $result = false; if ( Filesystem::isValidPath($target) - and Filesystem::isValidPath($source) - and !Filesystem::isFileBlacklisted($target) + && Filesystem::isValidPath($source) + && !Filesystem::isFileBlacklisted($target) ) { $source = $this->getRelativePath($absolutePath1); $target = $this->getRelativePath($absolutePath2); $exists = $this->file_exists($target); - if ($source == null or $target == null) { + if ($source == null || $target == null) { + return false; + } + + try { + $this->verifyPath(dirname($target), basename($target)); + } catch (InvalidPathException) { return false; } @@ -781,18 +748,20 @@ class View { // if it was a rename from a part file to a regular file it was a write and not a rename operation $this->emit_file_hooks_pre($exists, $target, $run); } elseif ($this->shouldEmitHooks($source)) { - \OC_Hook::emit( - Filesystem::CLASSNAME, Filesystem::signal_rename, - [ - Filesystem::signal_param_oldpath => $this->getHookPath($source), - Filesystem::signal_param_newpath => $this->getHookPath($target), - Filesystem::signal_param_run => &$run - ] - ); + $sourcePath = $this->getHookPath($source); + $targetPath = $this->getHookPath($target); + if ($sourcePath !== null && $targetPath !== null) { + \OC_Hook::emit( + Filesystem::CLASSNAME, Filesystem::signal_rename, + [ + Filesystem::signal_param_oldpath => $sourcePath, + Filesystem::signal_param_newpath => $targetPath, + Filesystem::signal_param_run => &$run + ] + ); + } } if ($run) { - $this->verifyPath(dirname($target), basename($target)); - $manager = Filesystem::getMountManager(); $mount1 = $this->getMount($source); $mount2 = $this->getMount($target); @@ -805,31 +774,38 @@ class View { try { $this->changeLock($target, ILockingProvider::LOCK_EXCLUSIVE, true); + if ($checkSubMounts) { + $movedMounts = $mountManager->findIn($this->getAbsolutePath($source)); + } else { + $movedMounts = []; + } + if ($internalPath1 === '') { - if ($mount1 instanceof MoveableMount) { - $sourceParentMount = $this->getMount(dirname($source)); - if ($sourceParentMount === $mount2 && $this->targetIsNotShared($storage2, $internalPath2)) { - /** - * @var \OC\Files\Mount\MountPoint | \OC\Files\Mount\MoveableMount $mount1 - */ - $sourceMountPoint = $mount1->getMountPoint(); - $result = $mount1->moveMount($absolutePath2); - $manager->moveMount($sourceMountPoint, $mount1->getMountPoint()); - } else { - $result = false; - } - } else { - $result = false; - } - // moving a file/folder within the same mount point + $sourceParentMount = $this->getMount(dirname($source)); + $movedMounts[] = $mount1; + $this->validateMountMove($movedMounts, $sourceParentMount, $mount2, !$this->targetIsNotShared($targetUser, $absolutePath2)); + /** + * @var \OC\Files\Mount\MountPoint | \OC\Files\Mount\MoveableMount $mount1 + */ + $sourceMountPoint = $mount1->getMountPoint(); + $result = $mount1->moveMount($absolutePath2); + $manager->moveMount($sourceMountPoint, $mount1->getMountPoint()); + + // moving a file/folder within the same mount point } elseif ($storage1 === $storage2) { + if (count($movedMounts) > 0) { + $this->validateMountMove($movedMounts, $mount1, $mount2, !$this->targetIsNotShared($targetUser, $absolutePath2)); + } if ($storage1) { $result = $storage1->rename($internalPath1, $internalPath2); } else { $result = false; } - // moving a file/folder between storages (from $storage1 to $storage2) + // moving a file/folder between storages (from $storage1 to $storage2) } else { + if (count($movedMounts) > 0) { + $this->validateMountMove($movedMounts, $mount1, $mount2, !$this->targetIsNotShared($targetUser, $absolutePath2)); + } $result = $storage2->moveFromStorage($storage1, $internalPath1, $internalPath2); } @@ -853,15 +829,19 @@ class View { $this->emit_file_hooks_post($exists, $target); } } elseif ($result) { - if ($this->shouldEmitHooks($source) and $this->shouldEmitHooks($target)) { - \OC_Hook::emit( - Filesystem::CLASSNAME, - Filesystem::signal_post_rename, - [ - Filesystem::signal_param_oldpath => $this->getHookPath($source), - Filesystem::signal_param_newpath => $this->getHookPath($target) - ] - ); + if ($this->shouldEmitHooks($source) && $this->shouldEmitHooks($target)) { + $sourcePath = $this->getHookPath($source); + $targetPath = $this->getHookPath($target); + if ($sourcePath !== null && $targetPath !== null) { + \OC_Hook::emit( + Filesystem::CLASSNAME, + Filesystem::signal_post_rename, + [ + Filesystem::signal_param_oldpath => $sourcePath, + Filesystem::signal_param_newpath => $targetPath, + ] + ); + } } } } @@ -876,6 +856,56 @@ class View { } /** + * @throws ForbiddenException + */ + private function validateMountMove(array $mounts, IMountPoint $sourceMount, IMountPoint $targetMount, bool $targetIsShared): void { + $targetPath = $this->getRelativePath($targetMount->getMountPoint()); + if ($targetPath) { + $targetPath = trim($targetPath, '/'); + } else { + $targetPath = $targetMount->getMountPoint(); + } + + $l = \OC::$server->get(IFactory::class)->get('files'); + foreach ($mounts as $mount) { + $sourcePath = $this->getRelativePath($mount->getMountPoint()); + if ($sourcePath) { + $sourcePath = trim($sourcePath, '/'); + } else { + $sourcePath = $mount->getMountPoint(); + } + + if (!$mount instanceof MoveableMount) { + throw new ForbiddenException($l->t('Storage %s cannot be moved', [$sourcePath]), false); + } + + if ($targetIsShared) { + if ($sourceMount instanceof SharedMount) { + throw new ForbiddenException($l->t('Moving a share (%s) into a shared folder is not allowed', [$sourcePath]), false); + } else { + throw new ForbiddenException($l->t('Moving a storage (%s) into a shared folder is not allowed', [$sourcePath]), false); + } + } + + if ($sourceMount !== $targetMount) { + if ($sourceMount instanceof SharedMount) { + if ($targetMount instanceof SharedMount) { + throw new ForbiddenException($l->t('Moving a share (%s) into another share (%s) is not allowed', [$sourcePath, $targetPath]), false); + } else { + throw new ForbiddenException($l->t('Moving a share (%s) into another storage (%s) is not allowed', [$sourcePath, $targetPath]), false); + } + } else { + if ($targetMount instanceof SharedMount) { + throw new ForbiddenException($l->t('Moving a storage (%s) into a share (%s) is not allowed', [$sourcePath, $targetPath]), false); + } else { + throw new ForbiddenException($l->t('Moving a storage (%s) into another storage (%s) is not allowed', [$sourcePath, $targetPath]), false); + } + } + } + } + } + + /** * Copy a file/folder from the source path to target path * * @param string $source source path @@ -890,13 +920,13 @@ class View { $result = false; if ( Filesystem::isValidPath($target) - and Filesystem::isValidPath($source) - and !Filesystem::isFileBlacklisted($target) + && Filesystem::isValidPath($source) + && !Filesystem::isFileBlacklisted($target) ) { $source = $this->getRelativePath($absolutePath1); $target = $this->getRelativePath($absolutePath2); - if ($source == null or $target == null) { + if ($source == null || $target == null) { return false; } $run = true; @@ -908,7 +938,7 @@ class View { try { $exists = $this->file_exists($target); - if ($this->shouldEmitHooks()) { + if ($this->shouldEmitHooks($target)) { \OC_Hook::emit( Filesystem::CLASSNAME, Filesystem::signal_copy, @@ -941,12 +971,14 @@ class View { $result = $storage2->copyFromStorage($storage1, $internalPath1, $internalPath2); } - $this->writeUpdate($storage2, $internalPath2); + if ($result) { + $this->copyUpdate($storage1, $storage2, $internalPath1, $internalPath2); + } $this->changeLock($target, ILockingProvider::LOCK_SHARED); $lockTypePath2 = ILockingProvider::LOCK_SHARED; - if ($this->shouldEmitHooks() && $result !== false) { + if ($this->shouldEmitHooks($target) && $result !== false) { \OC_Hook::emit( Filesystem::CLASSNAME, Filesystem::signal_post_copy, @@ -973,7 +1005,7 @@ class View { /** * @param string $path * @param string $mode 'r' or 'w' - * @return resource + * @return resource|false * @throws LockedException */ public function fopen($path, $mode) { @@ -1018,10 +1050,9 @@ class View { /** * @param string $path - * @return bool|string - * @throws \OCP\Files\InvalidPathException + * @throws InvalidPathException */ - public function toTmpFile($path) { + public function toTmpFile($path): string|false { $this->assertPathLength($path); if (Filesystem::isValidPath($path)) { $source = $this->fopen($path, 'r'); @@ -1042,7 +1073,7 @@ class View { * @param string $tmpFile * @param string $path * @return bool|mixed - * @throws \OCP\Files\InvalidPathException + * @throws InvalidPathException */ public function fromTmpFile($tmpFile, $path) { $this->assertPathLength($path); @@ -1061,9 +1092,12 @@ class View { $source = fopen($tmpFile, 'r'); if ($source) { $result = $this->file_put_contents($path, $source); - // $this->file_put_contents() might have already closed - // the resource, so we check it, before trying to close it - // to avoid messages in the error log. + /** + * $this->file_put_contents() might have already closed + * the resource, so we check it, before trying to close it + * to avoid messages in the error log. + * @psalm-suppress RedundantCondition false-positive + */ if (is_resource($source)) { fclose($source); } @@ -1081,7 +1115,7 @@ class View { /** * @param string $path * @return mixed - * @throws \OCP\Files\InvalidPathException + * @throws InvalidPathException */ public function getMimeType($path) { $this->assertPathLength($path); @@ -1092,9 +1126,8 @@ class View { * @param string $type * @param string $path * @param bool $raw - * @return bool|string */ - public function hash($type, $path, $raw = false) { + public function hash($type, $path, $raw = false): string|bool { $postFix = (substr($path, -1) === '/') ? '/' : ''; $absolutePath = Filesystem::normalizePath($this->getAbsolutePath($path)); if (Filesystem::isValidPath($path)) { @@ -1121,7 +1154,7 @@ class View { /** * @param string $path * @return mixed - * @throws \OCP\Files\InvalidPathException + * @throws InvalidPathException */ public function free_space($path = '/') { $this->assertPathLength($path); @@ -1135,9 +1168,6 @@ class View { /** * abstraction layer for basic filesystem functions: wrapper for \OC\Files\Storage\Storage * - * @param string $operation - * @param string $path - * @param array $hooks (optional) * @param mixed $extraParam (optional) * @return mixed * @throws LockedException @@ -1146,11 +1176,11 @@ class View { * files), processes hooks and proxies, sanitises paths, and finally passes them on to * \OC\Files\Storage\Storage for delegation to a storage backend for execution */ - private function basicOperation($operation, $path, $hooks = [], $extraParam = null) { + private function basicOperation(string $operation, string $path, array $hooks = [], $extraParam = null) { $postFix = (substr($path, -1) === '/') ? '/' : ''; $absolutePath = Filesystem::normalizePath($this->getAbsolutePath($path)); if (Filesystem::isValidPath($path) - and !Filesystem::isFileBlacklisted($path) + && !Filesystem::isFileBlacklisted($path) ) { $path = $this->getRelativePath($absolutePath); if ($path == null) { @@ -1163,9 +1193,9 @@ class View { } $run = $this->runHooks($hooks, $path); - /** @var \OC\Files\Storage\Storage $storage */ [$storage, $internalPath] = Filesystem::resolvePath($absolutePath . $postFix); - if ($run and $storage) { + if ($run && $storage) { + /** @var Storage $storage */ if (in_array('write', $hooks) || in_array('delete', $hooks)) { try { $this->changeLock($path, ILockingProvider::LOCK_EXCLUSIVE); @@ -1194,10 +1224,12 @@ class View { $this->removeUpdate($storage, $internalPath); } if ($result !== false && in_array('write', $hooks, true) && $operation !== 'fopen' && $operation !== 'touch') { - $this->writeUpdate($storage, $internalPath); + $isCreateOperation = $operation === 'mkdir' || ($operation === 'file_put_contents' && in_array('create', $hooks, true)); + $sizeDifference = $operation === 'mkdir' ? 0 : $result; + $this->writeUpdate($storage, $internalPath, null, $isCreateOperation ? $sizeDifference : null); } if ($result !== false && in_array('touch', $hooks)) { - $this->writeUpdate($storage, $internalPath, $extraParam); + $this->writeUpdate($storage, $internalPath, $extraParam, 0); } if ((in_array('write', $hooks) || in_array('delete', $hooks)) && ($operation !== 'fopen' || $result === false)) { @@ -1241,16 +1273,17 @@ class View { * get the path relative to the default root for hook usage * * @param string $path - * @return string + * @return ?string */ - private function getHookPath($path) { - if (!Filesystem::getView()) { + private function getHookPath($path): ?string { + $view = Filesystem::getView(); + if (!$view) { return $path; } - return Filesystem::getView()->getRelativePath($this->getAbsolutePath($path)); + return $view->getRelativePath($this->getAbsolutePath($path)); } - private function shouldEmitHooks($path = '') { + private function shouldEmitHooks(string $path = ''): bool { if ($path && Cache\Scanner::isPartialFile($path)) { return false; } @@ -1334,7 +1367,7 @@ class View { * If the file is not in cached it will be scanned * If the file has changed on storage the cache will be updated * - * @param \OC\Files\Storage\Storage $storage + * @param Storage $storage * @param string $internalPath * @param string $relativePath * @return ICacheEntry|bool @@ -1372,9 +1405,8 @@ class View { * get the filesystem info * * @param string $path - * @param boolean|string $includeMountPoints true to add mountpoint sizes, - * 'ext' to add only ext storage mount point sizes. Defaults to true. - * defaults to true + * @param bool|string $includeMountPoints true to add mountpoint sizes, + * 'ext' to add only ext storage mount point sizes. Defaults to true. * @return \OC\Files\FileInfo|false False if file does not exist */ public function getFileInfo($path, $includeMountPoints = true) { @@ -1382,9 +1414,6 @@ class View { if (!Filesystem::isValidPath($path)) { return false; } - if (Cache\Scanner::isPartialFile($path)) { - return $this->getPartFileInfo($path); - } $relativePath = $path; $path = Filesystem::normalizePath($this->fakeRoot . '/' . $path); @@ -1395,29 +1424,33 @@ class View { $data = $this->getCacheEntry($storage, $internalPath, $relativePath); if (!$data instanceof ICacheEntry) { + if (Cache\Scanner::isPartialFile($relativePath)) { + return $this->getPartFileInfo($relativePath); + } + return false; } if ($mount instanceof MoveableMount && $internalPath === '') { $data['permissions'] |= \OCP\Constants::PERMISSION_DELETE; } + if ($internalPath === '' && $data['name']) { + $data['name'] = basename($path); + } + $ownerId = $storage->getOwner($internalPath); $owner = null; - if ($ownerId !== null && $ownerId !== false) { + if ($ownerId !== false) { // ownerId might be null if files are accessed with an access token without file system access $owner = $this->getUserObjectForOwner($ownerId); } $info = new FileInfo($path, $storage, $internalPath, $data, $mount, $owner); if (isset($data['fileid'])) { - if ($includeMountPoints and $data['mimetype'] === 'httpd/unix-directory') { + if ($includeMountPoints && $data['mimetype'] === 'httpd/unix-directory') { //add the sizes of other mount points to the folder $extOnly = ($includeMountPoints === 'ext'); - $mounts = Filesystem::getMountManager()->findIn($path); - $info->setSubMounts(array_filter($mounts, function (IMountPoint $mount) use ($extOnly) { - $subStorage = $mount->getStorage(); - return !($extOnly && $subStorage instanceof \OCA\Files_Sharing\SharedStorage); - })); + $this->addSubMounts($info, $extOnly); } } @@ -1430,13 +1463,23 @@ class View { } /** + * Extend a FileInfo that was previously requested with `$includeMountPoints = false` to include the sub mounts + */ + public function addSubMounts(FileInfo $info, $extOnly = false): void { + $mounts = Filesystem::getMountManager()->findIn($info->getPath()); + $info->setSubMounts(array_filter($mounts, function (IMountPoint $mount) use ($extOnly) { + return !($extOnly && $mount instanceof SharedMount); + })); + } + + /** * get the content of a directory * * @param string $directory path under datadirectory * @param string $mimetype_filter limit returned content to this mimetype or mimepart * @return FileInfo[] */ - public function getDirectoryContent($directory, $mimetype_filter = '', \OCP\Files\FileInfo $directoryInfo = null) { + public function getDirectoryContent($directory, $mimetype_filter = '', ?\OCP\Files\FileInfo $directoryInfo = null) { $this->assertPathLength($directory); if (!Filesystem::isValidPath($directory)) { return []; @@ -1482,13 +1525,25 @@ class View { if ($sharingDisabled) { $content['permissions'] = $content['permissions'] & ~\OCP\Constants::PERMISSION_SHARE; } - $owner = $this->getUserObjectForOwner($storage->getOwner($content['path'])); + $ownerId = $storage->getOwner($content['path']); + if ($ownerId !== false) { + $owner = $this->getUserObjectForOwner($ownerId); + } else { + $owner = null; + } return new FileInfo($path . '/' . $content['name'], $storage, $content['path'], $content, $mount, $owner); }, $contents); $files = array_combine($fileNames, $fileInfos); //add a folder for any mountpoint in this directory and add the sizes of other mountpoints to the folders $mounts = Filesystem::getMountManager()->findIn($path); + + // make sure nested mounts are sorted after their parent mounts + // otherwise doesn't propagate the etag across storage boundaries correctly + usort($mounts, function (IMountPoint $a, IMountPoint $b) { + return $a->getMountPoint() <=> $b->getMountPoint(); + }); + $dirLength = strlen($path); foreach ($mounts as $mount) { $mountPoint = $mount->getMountPoint(); @@ -1521,6 +1576,32 @@ class View { if ($pos = strpos($relativePath, '/')) { //mountpoint inside subfolder add size to the correct folder $entryName = substr($relativePath, 0, $pos); + + // Create parent folders if the mountpoint is inside a subfolder that doesn't exist yet + if (!isset($files[$entryName])) { + try { + [$storage, ] = $this->resolvePath($path . '/' . $entryName); + // make sure we can create the mountpoint folder, even if the user has a quota of 0 + if ($storage->instanceOfStorage(Quota::class)) { + $storage->enableQuota(false); + } + + if ($this->mkdir($path . '/' . $entryName) !== false) { + $info = $this->getFileInfo($path . '/' . $entryName); + if ($info !== false) { + $files[$entryName] = $info; + } + } + + if ($storage->instanceOfStorage(Quota::class)) { + $storage->enableQuota(true); + } + } catch (\Exception $e) { + // Creating the parent folder might not be possible, for example due to a lack of permissions. + $this->logger->debug('Failed to create non-existent parent', ['exception' => $e, 'path' => $path . '/' . $entryName]); + } + } + if (isset($files[$entryName])) { $files[$entryName]->addSubEntry($rootEntry, $mountPoint); } @@ -1539,11 +1620,16 @@ class View { $rootEntry['path'] = substr(Filesystem::normalizePath($path . '/' . $rootEntry['name']), strlen($user) + 2); // full path without /$user/ // if sharing was disabled for the user we remove the share permissions - if (\OCP\Util::isSharingDisabledForUser()) { + if ($sharingDisabled) { $rootEntry['permissions'] = $rootEntry['permissions'] & ~\OCP\Constants::PERMISSION_SHARE; } - $owner = $this->getUserObjectForOwner($subStorage->getOwner('')); + $ownerId = $subStorage->getOwner(''); + if ($ownerId !== false) { + $owner = $this->getUserObjectForOwner($ownerId); + } else { + $owner = null; + } $files[$rootEntry->getName()] = new FileInfo($path . '/' . $rootEntry['name'], $subStorage, '', $rootEntry, $mount, $owner); } } @@ -1579,7 +1665,7 @@ class View { } $path = Filesystem::normalizePath($this->fakeRoot . '/' . $path); /** - * @var \OC\Files\Storage\Storage $storage + * @var Storage $storage * @var string $internalPath */ [$storage, $internalPath] = Filesystem::resolvePath($path); @@ -1660,7 +1746,12 @@ class View { $internalPath = $result['path']; $path = $mountPoint . $result['path']; $result['path'] = substr($mountPoint . $result['path'], $rootLength); - $owner = $userManager->get($storage->getOwner($internalPath)); + $ownerId = $storage->getOwner($internalPath); + if ($ownerId !== false) { + $owner = $userManager->get($ownerId); + } else { + $owner = null; + } $files[] = new FileInfo($path, $storage, $internalPath, $result, $mount, $owner); } } @@ -1679,7 +1770,12 @@ class View { $internalPath = $result['path']; $result['path'] = rtrim($relativeMountPoint . $result['path'], '/'); $path = rtrim($mountPoint . $internalPath, '/'); - $owner = $userManager->get($storage->getOwner($internalPath)); + $ownerId = $storage->getOwner($internalPath); + if ($ownerId !== false) { + $owner = $userManager->get($ownerId); + } else { + $owner = null; + } $files[] = new FileInfo($path, $storage, $internalPath, $result, $mount, $owner); } } @@ -1692,11 +1788,9 @@ class View { /** * Get the owner for a file or folder * - * @param string $path - * @return string the user id of the owner * @throws NotFoundException */ - public function getOwner($path) { + public function getOwner(string $path): string { $info = $this->getFileInfo($path); if (!$info) { throw new NotFoundException($path . ' not found while trying to get owner'); @@ -1713,18 +1807,14 @@ class View { * get the ETag for a file or folder * * @param string $path - * @return string + * @return string|false */ public function getETag($path) { - /** - * @var Storage\Storage $storage - * @var string $internalPath - */ [$storage, $internalPath] = $this->resolvePath($path); if ($storage) { return $storage->getETag($internalPath); } else { - return null; + return false; } } @@ -1738,43 +1828,25 @@ class View { * @return string * @throws NotFoundException */ - public function getPath($id, int $storageId = null) { + public function getPath($id, ?int $storageId = null): string { $id = (int)$id; - $manager = Filesystem::getMountManager(); - $mounts = $manager->findIn($this->fakeRoot); - $mounts[] = $manager->find($this->fakeRoot); - $mounts = array_filter($mounts); - // reverse the array, so we start with the storage this view is in - // which is the most likely to contain the file we're looking for - $mounts = array_reverse($mounts); - - // put non-shared mounts in front of the shared mount - // this prevents unneeded recursion into shares - usort($mounts, function (IMountPoint $a, IMountPoint $b) { - return $a instanceof SharedMount && (!$b instanceof SharedMount) ? 1 : -1; - }); + $rootFolder = Server::get(Files\IRootFolder::class); - if (!is_null($storageId)) { - $mounts = array_filter($mounts, function (IMountPoint $mount) use ($storageId) { - return $mount->getNumericStorageId() === $storageId; - }); + $node = $rootFolder->getFirstNodeByIdInPath($id, $this->getRoot()); + if ($node) { + if ($storageId === null || $storageId === $node->getStorage()->getCache()->getNumericStorageId()) { + return $this->getRelativePath($node->getPath()) ?? ''; + } + } else { + throw new NotFoundException(sprintf('File with id "%s" has not been found.', $id)); } - foreach ($mounts as $mount) { - /** - * @var \OC\Files\Mount\MountPoint $mount - */ - if ($mount->getStorage()) { - $cache = $mount->getStorage()->getCache(); - $internalPath = $cache->getPathById($id); - if (is_string($internalPath)) { - $fullPath = $mount->getMountPoint() . $internalPath; - if (!is_null($path = $this->getRelativePath($fullPath))) { - return $path; - } - } + foreach ($rootFolder->getByIdInPath($id, $this->getRoot()) as $node) { + if ($storageId === $node->getStorage()->getCache()->getNumericStorageId()) { + return $this->getRelativePath($node->getPath()) ?? ''; } } + throw new NotFoundException(sprintf('File with id "%s" has not been found.', $id)); } @@ -1782,13 +1854,13 @@ class View { * @param string $path * @throws InvalidPathException */ - private function assertPathLength($path) { + private function assertPathLength($path): void { $maxLen = min(PHP_MAXPATHLEN, 4000); // Check for the string length - performed using isset() instead of strlen() // because isset() is about 5x-40x faster. if (isset($path[$maxLen])) { $pathLen = strlen($path); - throw new \OCP\Files\InvalidPathException("Path length($pathLen) exceeds max path length($maxLen): $path"); + throw new InvalidPathException("Path length($pathLen) exceeds max path length($maxLen): $path"); } } @@ -1796,33 +1868,31 @@ class View { * check if it is allowed to move a mount point to a given target. * It is not allowed to move a mount point into a different mount point or * into an already shared folder - * - * @param IStorage $targetStorage - * @param string $targetInternalPath - * @return boolean - */ - private function targetIsNotShared(IStorage $targetStorage, string $targetInternalPath) { - // note: cannot use the view because the target is already locked - $fileId = (int)$targetStorage->getCache()->getId($targetInternalPath); - if ($fileId === -1) { - // target might not exist, need to check parent instead - $fileId = (int)$targetStorage->getCache()->getId(dirname($targetInternalPath)); - } - - // check if any of the parents were shared by the current owner (include collections) - $shares = Share::getItemShared( - 'folder', - $fileId, - \OC\Share\Constants::FORMAT_NONE, - null, - true - ); - - if (count($shares) > 0) { - $this->logger->debug( - 'It is not allowed to move one mount point into a shared folder', - ['app' => 'files']); - return false; + */ + private function targetIsNotShared(string $user, string $targetPath): bool { + $providers = [ + IShare::TYPE_USER, + IShare::TYPE_GROUP, + IShare::TYPE_EMAIL, + IShare::TYPE_CIRCLE, + IShare::TYPE_ROOM, + IShare::TYPE_DECK, + IShare::TYPE_SCIENCEMESH + ]; + $shareManager = Server::get(IManager::class); + /** @var IShare[] $shares */ + $shares = array_merge(...array_map(function (int $type) use ($shareManager, $user) { + return $shareManager->getSharesBy($user, $type); + }, $providers)); + + foreach ($shares as $share) { + $sharedPath = $share->getNode()->getPath(); + if ($targetPath === $sharedPath || str_starts_with($targetPath, $sharedPath . '/')) { + $this->logger->debug( + 'It is not allowed to move one mount point into a shared folder', + ['app' => 'files']); + return false; + } } return true; @@ -1830,15 +1900,17 @@ class View { /** * Get a fileinfo object for files that are ignored in the cache (part files) - * - * @param string $path - * @return \OCP\Files\FileInfo */ - private function getPartFileInfo($path) { + private function getPartFileInfo(string $path): \OC\Files\FileInfo { $mount = $this->getMount($path); $storage = $mount->getStorage(); $internalPath = $mount->getInternalPath($this->getAbsolutePath($path)); - $owner = \OC::$server->getUserManager()->get($storage->getOwner($internalPath)); + $ownerId = $storage->getOwner($internalPath); + if ($ownerId !== false) { + $owner = Server::get(IUserManager::class)->get($ownerId); + } else { + $owner = null; + } return new FileInfo( $this->getAbsolutePath($path), $storage, @@ -1861,27 +1933,44 @@ class View { /** * @param string $path * @param string $fileName + * @param bool $readonly Check only if the path is allowed for read-only access * @throws InvalidPathException */ - public function verifyPath($path, $fileName) { + public function verifyPath($path, $fileName, $readonly = false): void { + // All of the view's functions disallow '..' in the path so we can short cut if the path is invalid + if (!Filesystem::isValidPath($path ?: '/')) { + $l = \OCP\Util::getL10N('lib'); + throw new InvalidPathException($l->t('Path contains invalid segments')); + } + + // Short cut for read-only validation + if ($readonly) { + $validator = Server::get(FilenameValidator::class); + if ($validator->isForbidden($fileName)) { + $l = \OCP\Util::getL10N('lib'); + throw new InvalidPathException($l->t('Filename is a reserved word')); + } + return; + } + try { /** @type \OCP\Files\Storage $storage */ [$storage, $internalPath] = $this->resolvePath($path); $storage->verifyPath($internalPath, $fileName); } catch (ReservedWordException $ex) { - $l = \OC::$server->getL10N('lib'); - throw new InvalidPathException($l->t('File name is a reserved word')); + $l = \OCP\Util::getL10N('lib'); + throw new InvalidPathException($ex->getMessage() ?: $l->t('Filename is a reserved word')); } catch (InvalidCharacterInPathException $ex) { - $l = \OC::$server->getL10N('lib'); - throw new InvalidPathException($l->t('File name contains at least one invalid character')); + $l = \OCP\Util::getL10N('lib'); + throw new InvalidPathException($ex->getMessage() ?: $l->t('Filename contains at least one invalid character')); } catch (FileNameTooLongException $ex) { - $l = \OC::$server->getL10N('lib'); - throw new InvalidPathException($l->t('File name is too long')); + $l = \OCP\Util::getL10N('lib'); + throw new InvalidPathException($l->t('Filename is too long')); } catch (InvalidDirectoryException $ex) { - $l = \OC::$server->getL10N('lib'); + $l = \OCP\Util::getL10N('lib'); throw new InvalidPathException($l->t('Dot files are not allowed')); } catch (EmptyFileNameException $ex) { - $l = \OC::$server->getL10N('lib'); + $l = \OCP\Util::getL10N('lib'); throw new InvalidPathException($l->t('Empty filename is not allowed')); } } @@ -1918,10 +2007,10 @@ class View { * * @param string $absolutePath absolute path * @param bool $useParentMount true to return parent mount instead of whatever - * is mounted directly on the given path, false otherwise + * is mounted directly on the given path, false otherwise * @return IMountPoint mount point for which to apply locks */ - private function getMountForLock($absolutePath, $useParentMount = false) { + private function getMountForLock(string $absolutePath, bool $useParentMount = false): IMountPoint { $mount = Filesystem::getMountManager()->find($absolutePath); if ($useParentMount) { @@ -1954,24 +2043,22 @@ class View { } $mount = $this->getMountForLock($absolutePath, $lockMountPoint); - if ($mount) { - try { - $storage = $mount->getStorage(); - if ($storage && $storage->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { - $storage->acquireLock( - $mount->getInternalPath($absolutePath), - $type, - $this->lockingProvider - ); - } - } catch (LockedException $e) { - // rethrow with the a human-readable path - throw new LockedException( - $this->getPathRelativeToFiles($absolutePath), - $e, - $e->getExistingLock() + try { + $storage = $mount->getStorage(); + if ($storage && $storage->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { + $storage->acquireLock( + $mount->getInternalPath($absolutePath), + $type, + $this->lockingProvider ); } + } catch (LockedException $e) { + // rethrow with the human-readable path + throw new LockedException( + $path, + $e, + $e->getExistingLock() + ); } return true; @@ -1996,32 +2083,22 @@ class View { } $mount = $this->getMountForLock($absolutePath, $lockMountPoint); - if ($mount) { - try { - $storage = $mount->getStorage(); - if ($storage && $storage->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { - $storage->changeLock( - $mount->getInternalPath($absolutePath), - $type, - $this->lockingProvider - ); - } - } catch (LockedException $e) { - try { - // rethrow with the a human-readable path - throw new LockedException( - $this->getPathRelativeToFiles($absolutePath), - $e, - $e->getExistingLock() - ); - } catch (\InvalidArgumentException $ex) { - throw new LockedException( - $absolutePath, - $ex, - $e->getExistingLock() - ); - } + try { + $storage = $mount->getStorage(); + if ($storage && $storage->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { + $storage->changeLock( + $mount->getInternalPath($absolutePath), + $type, + $this->lockingProvider + ); } + } catch (LockedException $e) { + // rethrow with the a human-readable path + throw new LockedException( + $path, + $e, + $e->getExistingLock() + ); } return true; @@ -2045,15 +2122,13 @@ class View { } $mount = $this->getMountForLock($absolutePath, $lockMountPoint); - if ($mount) { - $storage = $mount->getStorage(); - if ($storage && $storage->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { - $storage->releaseLock( - $mount->getInternalPath($absolutePath), - $type, - $this->lockingProvider - ); - } + $storage = $mount->getStorage(); + if ($storage && $storage->instanceOfStorage('\OCP\Files\Storage\ILockingStorage')) { + $storage->releaseLock( + $mount->getInternalPath($absolutePath), + $type, + $this->lockingProvider + ); } return true; @@ -2128,7 +2203,7 @@ class View { return ($pathSegments[2] === 'files') && (count($pathSegments) > 3); } - return strpos($path, '/appdata_') !== 0; + return !str_starts_with($path, '/appdata_'); } /** @@ -2138,7 +2213,7 @@ class View { * @param string $absolutePath absolute path which is under "files" * * @return string path relative to "files" with trimmed slashes or null - * if the path was NOT relative to files + * if the path was NOT relative to files * * @throws \InvalidArgumentException if the given path was not under "files" * @since 8.1.0 |