diff options
Diffstat (limited to 'apps/files_external/lib/Lib/Storage')
-rw-r--r-- | apps/files_external/lib/Lib/Storage/AmazonS3.php | 760 | ||||
-rw-r--r-- | apps/files_external/lib/Lib/Storage/FTP.php | 364 | ||||
-rw-r--r-- | apps/files_external/lib/Lib/Storage/FtpConnection.php | 222 | ||||
-rw-r--r-- | apps/files_external/lib/Lib/Storage/OwnCloud.php | 63 | ||||
-rw-r--r-- | apps/files_external/lib/Lib/Storage/SFTP.php | 523 | ||||
-rw-r--r-- | apps/files_external/lib/Lib/Storage/SFTPReadStream.php | 217 | ||||
-rw-r--r-- | apps/files_external/lib/Lib/Storage/SFTPWriteStream.php | 165 | ||||
-rw-r--r-- | apps/files_external/lib/Lib/Storage/SMB.php | 727 | ||||
-rw-r--r-- | apps/files_external/lib/Lib/Storage/StreamWrapper.php | 99 | ||||
-rw-r--r-- | apps/files_external/lib/Lib/Storage/Swift.php | 593 | ||||
-rw-r--r-- | apps/files_external/lib/Lib/Storage/SystemBridge.php | 27 |
11 files changed, 3760 insertions, 0 deletions
diff --git a/apps/files_external/lib/Lib/Storage/AmazonS3.php b/apps/files_external/lib/Lib/Storage/AmazonS3.php new file mode 100644 index 00000000000..5dc9e114532 --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/AmazonS3.php @@ -0,0 +1,760 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Storage; + +use Aws\S3\Exception\S3Exception; +use Icewind\Streams\CallbackWrapper; +use Icewind\Streams\CountWrapper; +use Icewind\Streams\IteratorDirectory; +use OC\Files\Cache\CacheEntry; +use OC\Files\ObjectStore\S3ConnectionTrait; +use OC\Files\ObjectStore\S3ObjectTrait; +use OC\Files\Storage\Common; +use OCP\Cache\CappedMemoryCache; +use OCP\Constants; +use OCP\Files\FileInfo; +use OCP\Files\IMimeTypeDetector; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\ITempManager; +use OCP\Server; +use Psr\Log\LoggerInterface; + +class AmazonS3 extends Common { + use S3ConnectionTrait; + use S3ObjectTrait; + + private LoggerInterface $logger; + + public function needsPartFile(): bool { + return false; + } + + /** @var CappedMemoryCache<array|false> */ + private CappedMemoryCache $objectCache; + + /** @var CappedMemoryCache<bool> */ + private CappedMemoryCache $directoryCache; + + /** @var CappedMemoryCache<array> */ + private CappedMemoryCache $filesCache; + + private IMimeTypeDetector $mimeDetector; + private ?bool $versioningEnabled = null; + private ICache $memCache; + + public function __construct(array $parameters) { + parent::__construct($parameters); + $this->parseParams($parameters); + $this->id = 'amazon::external::' . md5($this->params['hostname'] . ':' . $this->params['bucket'] . ':' . $this->params['key']); + $this->objectCache = new CappedMemoryCache(); + $this->directoryCache = new CappedMemoryCache(); + $this->filesCache = new CappedMemoryCache(); + $this->mimeDetector = Server::get(IMimeTypeDetector::class); + /** @var ICacheFactory $cacheFactory */ + $cacheFactory = Server::get(ICacheFactory::class); + $this->memCache = $cacheFactory->createLocal('s3-external'); + $this->logger = Server::get(LoggerInterface::class); + } + + private function normalizePath(string $path): string { + $path = trim($path, '/'); + + if (!$path) { + $path = '.'; + } + + return $path; + } + + private function isRoot(string $path): bool { + return $path === '.'; + } + + private function cleanKey(string $path): string { + if ($this->isRoot($path)) { + return '/'; + } + return $path; + } + + private function clearCache(): void { + $this->objectCache = new CappedMemoryCache(); + $this->directoryCache = new CappedMemoryCache(); + $this->filesCache = new CappedMemoryCache(); + } + + private function invalidateCache(string $key): void { + unset($this->objectCache[$key]); + $keys = array_keys($this->objectCache->getData()); + $keyLength = strlen($key); + foreach ($keys as $existingKey) { + if (substr($existingKey, 0, $keyLength) === $key) { + unset($this->objectCache[$existingKey]); + } + } + unset($this->filesCache[$key]); + $keys = array_keys($this->directoryCache->getData()); + $keyLength = strlen($key); + foreach ($keys as $existingKey) { + if (substr($existingKey, 0, $keyLength) === $key) { + unset($this->directoryCache[$existingKey]); + } + } + unset($this->directoryCache[$key]); + } + + private function headObject(string $key): array|false { + if (!isset($this->objectCache[$key])) { + try { + $this->objectCache[$key] = $this->getConnection()->headObject([ + 'Bucket' => $this->bucket, + 'Key' => $key + ] + $this->getSSECParameters())->toArray(); + } catch (S3Exception $e) { + if ($e->getStatusCode() >= 500) { + throw $e; + } + $this->objectCache[$key] = false; + } + } + + if (is_array($this->objectCache[$key]) && !isset($this->objectCache[$key]['Key'])) { + /** @psalm-suppress InvalidArgument Psalm doesn't understand nested arrays well */ + $this->objectCache[$key]['Key'] = $key; + } + return $this->objectCache[$key]; + } + + /** + * Return true if directory exists + * + * There are no folders in s3. A folder like structure could be archived + * by prefixing files with the folder name. + * + * Implementation from flysystem-aws-s3-v3: + * https://github.com/thephpleague/flysystem-aws-s3-v3/blob/8241e9cc5b28f981e0d24cdaf9867f14c7498ae4/src/AwsS3Adapter.php#L670-L694 + * + * @throws \Exception + */ + private function doesDirectoryExist(string $path): bool { + if ($path === '.' || $path === '') { + return true; + } + $path = rtrim($path, '/') . '/'; + + if (isset($this->directoryCache[$path])) { + return $this->directoryCache[$path]; + } + try { + // Maybe this isn't an actual key, but a prefix. + // Do a prefix listing of objects to determine. + $result = $this->getConnection()->listObjectsV2([ + 'Bucket' => $this->bucket, + 'Prefix' => $path, + 'MaxKeys' => 1, + ]); + + if (isset($result['Contents'])) { + $this->directoryCache[$path] = true; + return true; + } + + // empty directories have their own object + $object = $this->headObject($path); + + if ($object) { + $this->directoryCache[$path] = true; + return true; + } + } catch (S3Exception $e) { + if ($e->getStatusCode() >= 400 && $e->getStatusCode() < 500) { + $this->directoryCache[$path] = false; + } + throw $e; + } + + + $this->directoryCache[$path] = false; + return false; + } + + protected function remove(string $path): bool { + // remember fileType to reduce http calls + $fileType = $this->filetype($path); + if ($fileType === 'dir') { + return $this->rmdir($path); + } elseif ($fileType === 'file') { + return $this->unlink($path); + } else { + return false; + } + } + + public function mkdir(string $path): bool { + $path = $this->normalizePath($path); + + if ($this->is_dir($path)) { + return false; + } + + try { + $this->getConnection()->putObject([ + 'Bucket' => $this->bucket, + 'Key' => $path . '/', + 'Body' => '', + 'ContentType' => FileInfo::MIMETYPE_FOLDER + ] + $this->getSSECParameters()); + $this->testTimeout(); + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + + $this->invalidateCache($path); + + return true; + } + + public function file_exists(string $path): bool { + return $this->filetype($path) !== false; + } + + + public function rmdir(string $path): bool { + $path = $this->normalizePath($path); + + if ($this->isRoot($path)) { + return $this->clearBucket(); + } + + if (!$this->file_exists($path)) { + return false; + } + + $this->invalidateCache($path); + return $this->batchDelete($path); + } + + protected function clearBucket(): bool { + $this->clearCache(); + return $this->batchDelete(); + } + + private function batchDelete(?string $path = null): bool { + // TODO explore using https://docs.aws.amazon.com/aws-sdk-php/v3/api/class-Aws.S3.BatchDelete.html + $params = [ + 'Bucket' => $this->bucket + ]; + if ($path !== null) { + $params['Prefix'] = $path . '/'; + } + try { + $connection = $this->getConnection(); + // Since there are no real directories on S3, we need + // to delete all objects prefixed with the path. + do { + // instead of the iterator, manually loop over the list ... + $objects = $connection->listObjects($params); + // ... so we can delete the files in batches + if (isset($objects['Contents'])) { + $connection->deleteObjects([ + 'Bucket' => $this->bucket, + 'Delete' => [ + 'Objects' => $objects['Contents'] + ] + ]); + $this->testTimeout(); + } + // we reached the end when the list is no longer truncated + } while ($objects['IsTruncated']); + if ($path !== '' && $path !== null) { + $this->deleteObject($path); + } + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + return true; + } + + public function opendir(string $path) { + try { + $content = iterator_to_array($this->getDirectoryContent($path)); + return IteratorDirectory::wrap(array_map(function (array $item) { + return $item['name']; + }, $content)); + } catch (S3Exception $e) { + return false; + } + } + + public function stat(string $path): array|false { + $path = $this->normalizePath($path); + + if ($this->is_dir($path)) { + $stat = $this->getDirectoryMetaData($path); + } else { + $object = $this->headObject($path); + if ($object === false) { + return false; + } + $stat = $this->objectToMetaData($object); + } + $stat['atime'] = time(); + + return $stat; + } + + /** + * Return content length for object + * + * When the information is already present (e.g. opendir has been called before) + * this value is return. Otherwise a headObject is emitted. + */ + private function getContentLength(string $path): int { + if (isset($this->filesCache[$path])) { + return (int)$this->filesCache[$path]['ContentLength']; + } + + $result = $this->headObject($path); + if (isset($result['ContentLength'])) { + return (int)$result['ContentLength']; + } + + return 0; + } + + /** + * Return last modified for object + * + * When the information is already present (e.g. opendir has been called before) + * this value is return. Otherwise a headObject is emitted. + */ + private function getLastModified(string $path): string { + if (isset($this->filesCache[$path])) { + return $this->filesCache[$path]['LastModified']; + } + + $result = $this->headObject($path); + if (isset($result['LastModified'])) { + return $result['LastModified']; + } + + return 'now'; + } + + public function is_dir(string $path): bool { + $path = $this->normalizePath($path); + + if (isset($this->filesCache[$path])) { + return false; + } + + try { + return $this->doesDirectoryExist($path); + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + } + + public function filetype(string $path): string|false { + $path = $this->normalizePath($path); + + if ($this->isRoot($path)) { + return 'dir'; + } + + try { + if (isset($this->directoryCache[$path]) && $this->directoryCache[$path]) { + return 'dir'; + } + if (isset($this->filesCache[$path]) || $this->headObject($path)) { + return 'file'; + } + if ($this->doesDirectoryExist($path)) { + return 'dir'; + } + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + + return false; + } + + public function getPermissions(string $path): int { + $type = $this->filetype($path); + if (!$type) { + return 0; + } + return $type === 'dir' ? Constants::PERMISSION_ALL : Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE; + } + + public function unlink(string $path): bool { + $path = $this->normalizePath($path); + + if ($this->is_dir($path)) { + return $this->rmdir($path); + } + + try { + $this->deleteObject($path); + $this->invalidateCache($path); + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + + return true; + } + + public function fopen(string $path, string $mode) { + $path = $this->normalizePath($path); + + switch ($mode) { + case 'r': + case 'rb': + // Don't try to fetch empty files + $stat = $this->stat($path); + if (is_array($stat) && isset($stat['size']) && $stat['size'] === 0) { + return fopen('php://memory', $mode); + } + + try { + return $this->readObject($path); + } catch (\Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + case 'w': + case 'wb': + $tmpFile = Server::get(ITempManager::class)->getTemporaryFile(); + + $handle = fopen($tmpFile, 'w'); + return CallbackWrapper::wrap($handle, null, null, function () use ($path, $tmpFile): void { + $this->writeBack($tmpFile, $path); + }); + case 'a': + case 'ab': + case 'r+': + case 'w+': + case 'wb+': + case 'a+': + case 'x': + case 'x+': + case 'c': + case 'c+': + if (strrpos($path, '.') !== false) { + $ext = substr($path, strrpos($path, '.')); + } else { + $ext = ''; + } + $tmpFile = Server::get(ITempManager::class)->getTemporaryFile($ext); + if ($this->file_exists($path)) { + $source = $this->readObject($path); + file_put_contents($tmpFile, $source); + } + + $handle = fopen($tmpFile, $mode); + return CallbackWrapper::wrap($handle, null, null, function () use ($path, $tmpFile): void { + $this->writeBack($tmpFile, $path); + }); + } + return false; + } + + public function touch(string $path, ?int $mtime = null): bool { + if (is_null($mtime)) { + $mtime = time(); + } + $metadata = [ + 'lastmodified' => gmdate(\DateTime::RFC1123, $mtime) + ]; + + try { + if ($this->file_exists($path)) { + return false; + } + + $mimeType = $this->mimeDetector->detectPath($path); + $this->getConnection()->putObject([ + 'Bucket' => $this->bucket, + 'Key' => $this->cleanKey($path), + 'Metadata' => $metadata, + 'Body' => '', + 'ContentType' => $mimeType, + 'MetadataDirective' => 'REPLACE', + ] + $this->getSSECParameters()); + $this->testTimeout(); + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + + $this->invalidateCache($path); + return true; + } + + public function copy(string $source, string $target, ?bool $isFile = null): bool { + $source = $this->normalizePath($source); + $target = $this->normalizePath($target); + + if ($isFile === true || $this->is_file($source)) { + try { + $this->copyObject($source, $target, [ + 'StorageClass' => $this->storageClass, + ]); + $this->testTimeout(); + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + } else { + $this->remove($target); + + try { + $this->mkdir($target); + $this->testTimeout(); + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + + foreach ($this->getDirectoryContent($source) as $item) { + $childSource = $source . '/' . $item['name']; + $childTarget = $target . '/' . $item['name']; + $this->copy($childSource, $childTarget, $item['mimetype'] !== FileInfo::MIMETYPE_FOLDER); + } + } + + $this->invalidateCache($target); + + return true; + } + + public function rename(string $source, string $target): bool { + $source = $this->normalizePath($source); + $target = $this->normalizePath($target); + + if ($this->is_file($source)) { + if ($this->copy($source, $target) === false) { + return false; + } + + if ($this->unlink($source) === false) { + $this->unlink($target); + return false; + } + } else { + if ($this->copy($source, $target) === false) { + return false; + } + + if ($this->rmdir($source) === false) { + $this->rmdir($target); + return false; + } + } + + return true; + } + + public function test(): bool { + $this->getConnection()->headBucket([ + 'Bucket' => $this->bucket + ]); + return true; + } + + public function getId(): string { + return $this->id; + } + + public function writeBack(string $tmpFile, string $path): bool { + try { + $source = fopen($tmpFile, 'r'); + $this->writeObject($path, $source, $this->mimeDetector->detectPath($path)); + $this->invalidateCache($path); + + unlink($tmpFile); + return true; + } catch (S3Exception $e) { + $this->logger->error($e->getMessage(), [ + 'app' => 'files_external', + 'exception' => $e, + ]); + return false; + } + } + + /** + * check if curl is installed + */ + public static function checkDependencies(): bool { + return true; + } + + public function getDirectoryContent(string $directory): \Traversable { + $path = $this->normalizePath($directory); + + if ($this->isRoot($path)) { + $path = ''; + } else { + $path .= '/'; + } + + $results = $this->getConnection()->getPaginator('ListObjectsV2', [ + 'Bucket' => $this->bucket, + 'Delimiter' => '/', + 'Prefix' => $path, + ]); + + foreach ($results as $result) { + // sub folders + if (is_array($result['CommonPrefixes'])) { + foreach ($result['CommonPrefixes'] as $prefix) { + $dir = $this->getDirectoryMetaData($prefix['Prefix']); + if ($dir) { + yield $dir; + } + } + } + if (is_array($result['Contents'])) { + foreach ($result['Contents'] as $object) { + $this->objectCache[$object['Key']] = $object; + if ($object['Key'] !== $path) { + yield $this->objectToMetaData($object); + } + } + } + } + } + + private function objectToMetaData(array $object): array { + return [ + 'name' => basename($object['Key']), + 'mimetype' => $this->mimeDetector->detectPath($object['Key']), + 'mtime' => strtotime($object['LastModified']), + 'storage_mtime' => strtotime($object['LastModified']), + 'etag' => trim($object['ETag'], '"'), + 'permissions' => Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE, + 'size' => (int)($object['Size'] ?? $object['ContentLength']), + ]; + } + + private function getDirectoryMetaData(string $path): ?array { + $path = trim($path, '/'); + // when versioning is enabled, delete markers are returned as part of CommonPrefixes + // resulting in "ghost" folders, verify that each folder actually exists + if ($this->versioningEnabled() && !$this->doesDirectoryExist($path)) { + return null; + } + $cacheEntry = $this->getCache()->get($path); + if ($cacheEntry instanceof CacheEntry) { + return $cacheEntry->getData(); + } else { + return [ + 'name' => basename($path), + 'mimetype' => FileInfo::MIMETYPE_FOLDER, + 'mtime' => time(), + 'storage_mtime' => time(), + 'etag' => uniqid(), + 'permissions' => Constants::PERMISSION_ALL, + 'size' => -1, + ]; + } + } + + public function versioningEnabled(): bool { + if ($this->versioningEnabled === null) { + $cached = $this->memCache->get('versioning-enabled::' . $this->getBucket()); + if ($cached === null) { + $this->versioningEnabled = $this->getVersioningStatusFromBucket(); + $this->memCache->set('versioning-enabled::' . $this->getBucket(), $this->versioningEnabled, 60); + } else { + $this->versioningEnabled = $cached; + } + } + return $this->versioningEnabled; + } + + protected function getVersioningStatusFromBucket(): bool { + try { + $result = $this->getConnection()->getBucketVersioning(['Bucket' => $this->getBucket()]); + return $result->get('Status') === 'Enabled'; + } catch (S3Exception $s3Exception) { + // This is needed for compatibility with Storj gateway which does not support versioning yet + if ($s3Exception->getAwsErrorCode() === 'NotImplemented' || $s3Exception->getAwsErrorCode() === 'AccessDenied') { + return false; + } + throw $s3Exception; + } + } + + public function hasUpdated(string $path, int $time): bool { + // for files we can get the proper mtime + if ($path !== '' && $object = $this->headObject($path)) { + $stat = $this->objectToMetaData($object); + return $stat['mtime'] > $time; + } else { + // for directories, the only real option we have is to do a prefix listing and iterate over all objects + // however, since this is just as expensive as just re-scanning the directory, we can simply return true + // and have the scanner figure out if anything has actually changed + return true; + } + } + + public function writeStream(string $path, $stream, ?int $size = null): int { + if ($size === null) { + $size = 0; + // track the number of bytes read from the input stream to return as the number of written bytes. + $stream = CountWrapper::wrap($stream, function (int $writtenSize) use (&$size): void { + $size = $writtenSize; + }); + } + + if (!is_resource($stream)) { + throw new \InvalidArgumentException('Invalid stream provided'); + } + + $path = $this->normalizePath($path); + $this->writeObject($path, $stream, $this->mimeDetector->detectPath($path)); + $this->invalidateCache($path); + + return $size; + } +} diff --git a/apps/files_external/lib/Lib/Storage/FTP.php b/apps/files_external/lib/Lib/Storage/FTP.php new file mode 100644 index 00000000000..944964de7a6 --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/FTP.php @@ -0,0 +1,364 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Storage; + +use Icewind\Streams\CallbackWrapper; +use Icewind\Streams\CountWrapper; +use Icewind\Streams\IteratorDirectory; +use OC\Files\Storage\Common; +use OC\Files\Storage\PolyFill\CopyDirectory; +use OCP\Constants; +use OCP\Files\FileInfo; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\StorageNotAvailableException; +use OCP\ITempManager; +use OCP\Server; +use Psr\Log\LoggerInterface; + +class FTP extends Common { + use CopyDirectory; + + private $root; + private $host; + private $password; + private $username; + private $secure; + private $port; + private $utf8Mode; + + /** @var FtpConnection|null */ + private $connection; + + public function __construct(array $parameters) { + if (isset($parameters['host']) && isset($parameters['user']) && isset($parameters['password'])) { + $this->host = $parameters['host']; + $this->username = $parameters['user']; + $this->password = $parameters['password']; + if (isset($parameters['secure'])) { + if (is_string($parameters['secure'])) { + $this->secure = ($parameters['secure'] === 'true'); + } else { + $this->secure = (bool)$parameters['secure']; + } + } else { + $this->secure = false; + } + $this->root = isset($parameters['root']) ? '/' . ltrim($parameters['root']) : '/'; + $this->port = $parameters['port'] ?? 21; + $this->utf8Mode = isset($parameters['utf8']) && $parameters['utf8']; + } else { + throw new \Exception('Creating ' . self::class . ' storage failed, required parameters not set'); + } + } + + public function __destruct() { + $this->connection = null; + } + + protected function getConnection(): FtpConnection { + if (!$this->connection) { + try { + $this->connection = new FtpConnection( + $this->secure, + $this->host, + $this->port, + $this->username, + $this->password + ); + } catch (\Exception $e) { + throw new StorageNotAvailableException('Failed to create ftp connection', 0, $e); + } + if ($this->utf8Mode) { + if (!$this->connection->setUtf8Mode()) { + throw new StorageNotAvailableException('Could not set UTF-8 mode'); + } + } + } + + return $this->connection; + } + + public function getId(): string { + return 'ftp::' . $this->username . '@' . $this->host . '/' . $this->root; + } + + protected function buildPath(string $path): string { + return rtrim($this->root . '/' . $path, '/'); + } + + public static function checkDependencies(): array|bool { + if (function_exists('ftp_login')) { + return true; + } else { + return ['ftp']; + } + } + + public function filemtime(string $path): int|false { + $result = $this->getConnection()->mdtm($this->buildPath($path)); + + if ($result === -1) { + if ($this->is_dir($path)) { + $list = $this->getConnection()->mlsd($this->buildPath($path)); + if (!$list) { + Server::get(LoggerInterface::class)->warning("Unable to get last modified date for ftp folder ($path), failed to list folder contents"); + return time(); + } + $currentDir = current(array_filter($list, function ($item) { + return $item['type'] === 'cdir'; + })); + if ($currentDir) { + [$modify] = explode('.', $currentDir['modify'] ?? '', 2); + $time = \DateTime::createFromFormat('YmdHis', $modify); + if ($time === false) { + throw new \Exception("Invalid date format for directory: $currentDir"); + } + return $time->getTimestamp(); + } else { + Server::get(LoggerInterface::class)->warning("Unable to get last modified date for ftp folder ($path), folder contents doesn't include current folder"); + return time(); + } + } else { + return false; + } + } else { + return $result; + } + } + + public function filesize(string $path): false|int|float { + $result = $this->getConnection()->size($this->buildPath($path)); + if ($result === -1) { + return false; + } else { + return $result; + } + } + + public function rmdir(string $path): bool { + if ($this->is_dir($path)) { + $result = $this->getConnection()->rmdir($this->buildPath($path)); + // recursive rmdir support depends on the ftp server + if ($result) { + return $result; + } else { + return $this->recursiveRmDir($path); + } + } elseif ($this->is_file($path)) { + return $this->unlink($path); + } else { + return false; + } + } + + private function recursiveRmDir(string $path): bool { + $contents = $this->getDirectoryContent($path); + $result = true; + foreach ($contents as $content) { + if ($content['mimetype'] === FileInfo::MIMETYPE_FOLDER) { + $result = $result && $this->recursiveRmDir($path . '/' . $content['name']); + } else { + $result = $result && $this->getConnection()->delete($this->buildPath($path . '/' . $content['name'])); + } + } + $result = $result && $this->getConnection()->rmdir($this->buildPath($path)); + + return $result; + } + + public function test(): bool { + try { + return $this->getConnection()->systype() !== false; + } catch (\Exception $e) { + return false; + } + } + + public function stat(string $path): array|false { + if (!$this->file_exists($path)) { + return false; + } + return [ + 'mtime' => $this->filemtime($path), + 'size' => $this->filesize($path), + ]; + } + + public function file_exists(string $path): bool { + if ($path === '' || $path === '.' || $path === '/') { + return true; + } + return $this->filetype($path) !== false; + } + + public function unlink(string $path): bool { + switch ($this->filetype($path)) { + case 'dir': + return $this->rmdir($path); + case 'file': + return $this->getConnection()->delete($this->buildPath($path)); + default: + return false; + } + } + + public function opendir(string $path) { + $files = $this->getConnection()->nlist($this->buildPath($path)); + return IteratorDirectory::wrap($files); + } + + public function mkdir(string $path): bool { + if ($this->is_dir($path)) { + return false; + } + return $this->getConnection()->mkdir($this->buildPath($path)) !== false; + } + + public function is_dir(string $path): bool { + if ($path === '') { + return true; + } + if ($this->getConnection()->chdir($this->buildPath($path)) === true) { + $this->getConnection()->chdir('/'); + return true; + } else { + return false; + } + } + + public function is_file(string $path): bool { + return $this->filesize($path) !== false; + } + + public function filetype(string $path): string|false { + if ($this->is_dir($path)) { + return 'dir'; + } elseif ($this->is_file($path)) { + return 'file'; + } else { + return false; + } + } + + public function fopen(string $path, string $mode) { + $useExisting = true; + switch ($mode) { + case 'r': + case 'rb': + return $this->readStream($path); + case 'w': + case 'w+': + case 'wb': + case 'wb+': + $useExisting = false; + // no break + case 'a': + case 'ab': + case 'r+': + case 'a+': + case 'x': + case 'x+': + case 'c': + case 'c+': + //emulate these + if ($useExisting and $this->file_exists($path)) { + if (!$this->isUpdatable($path)) { + return false; + } + $tmpFile = $this->getCachedFile($path); + } else { + if (!$this->isCreatable(dirname($path))) { + return false; + } + $tmpFile = Server::get(ITempManager::class)->getTemporaryFile(); + } + $source = fopen($tmpFile, $mode); + return CallbackWrapper::wrap($source, null, null, function () use ($tmpFile, $path): void { + $this->writeStream($path, fopen($tmpFile, 'r')); + unlink($tmpFile); + }); + } + return false; + } + + public function writeStream(string $path, $stream, ?int $size = null): int { + if ($size === null) { + $stream = CountWrapper::wrap($stream, function ($writtenSize) use (&$size): void { + $size = $writtenSize; + }); + } + + $this->getConnection()->fput($this->buildPath($path), $stream); + fclose($stream); + + return $size; + } + + public function readStream(string $path) { + $stream = fopen('php://temp', 'w+'); + $result = $this->getConnection()->fget($stream, $this->buildPath($path)); + rewind($stream); + + if (!$result) { + fclose($stream); + return false; + } + return $stream; + } + + public function touch(string $path, ?int $mtime = null): bool { + if ($this->file_exists($path)) { + return false; + } else { + $this->file_put_contents($path, ''); + return true; + } + } + + public function rename(string $source, string $target): bool { + $this->unlink($target); + return $this->getConnection()->rename($this->buildPath($source), $this->buildPath($target)); + } + + public function getDirectoryContent(string $directory): \Traversable { + $files = $this->getConnection()->mlsd($this->buildPath($directory)); + $mimeTypeDetector = Server::get(IMimeTypeDetector::class); + + foreach ($files as $file) { + $name = $file['name']; + if ($file['type'] === 'cdir' || $file['type'] === 'pdir') { + continue; + } + $permissions = Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE; + $isDir = $file['type'] === 'dir'; + if ($isDir) { + $permissions += Constants::PERMISSION_CREATE; + } + + $data = []; + $data['mimetype'] = $isDir ? FileInfo::MIMETYPE_FOLDER : $mimeTypeDetector->detectPath($name); + + // strip fractional seconds + [$modify] = explode('.', $file['modify'], 2); + $mtime = \DateTime::createFromFormat('YmdGis', $modify); + $data['mtime'] = $mtime === false ? time() : $mtime->getTimestamp(); + if ($isDir) { + $data['size'] = -1; //unknown + } elseif (isset($file['size'])) { + $data['size'] = $file['size']; + } else { + $data['size'] = $this->filesize($directory . '/' . $name); + } + $data['etag'] = uniqid(); + $data['storage_mtime'] = $data['mtime']; + $data['permissions'] = $permissions; + $data['name'] = $name; + + yield $data; + } + } +} diff --git a/apps/files_external/lib/Lib/Storage/FtpConnection.php b/apps/files_external/lib/Lib/Storage/FtpConnection.php new file mode 100644 index 00000000000..a064bf9b100 --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/FtpConnection.php @@ -0,0 +1,222 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files_External\Lib\Storage; + +/** + * Low level wrapper around the ftp functions that smooths over some difference between servers + */ +class FtpConnection { + private \FTP\Connection $connection; + + public function __construct(bool $secure, string $hostname, int $port, string $username, string $password) { + if ($secure) { + $connection = ftp_ssl_connect($hostname, $port); + } else { + $connection = ftp_connect($hostname, $port); + } + + if ($connection === false) { + throw new \Exception('Failed to connect to ftp'); + } + + if (ftp_login($connection, $username, $password) === false) { + throw new \Exception('Failed to connect to login to ftp'); + } + + ftp_pasv($connection, true); + $this->connection = $connection; + } + + public function __destruct() { + ftp_close($this->connection); + } + + public function setUtf8Mode(): bool { + $response = ftp_raw($this->connection, 'OPTS UTF8 ON'); + return substr($response[0], 0, 3) === '200'; + } + + public function fput(string $path, $handle) { + return @ftp_fput($this->connection, $path, $handle, FTP_BINARY); + } + + public function fget($handle, string $path) { + return @ftp_fget($this->connection, $handle, $path, FTP_BINARY); + } + + public function mkdir(string $path) { + return @ftp_mkdir($this->connection, $path); + } + + public function chdir(string $path) { + return @ftp_chdir($this->connection, $path); + } + + public function delete(string $path) { + return @ftp_delete($this->connection, $path); + } + + public function rmdir(string $path) { + return @ftp_rmdir($this->connection, $path); + } + + public function rename(string $source, string $target) { + return @ftp_rename($this->connection, $source, $target); + } + + public function mdtm(string $path): int { + $result = @ftp_mdtm($this->connection, $path); + + // filezilla doesn't like empty path with mdtm + if ($result === -1 && $path === '') { + $result = @ftp_mdtm($this->connection, '/'); + } + return $result; + } + + public function size(string $path) { + return @ftp_size($this->connection, $path); + } + + public function systype() { + return @ftp_systype($this->connection); + } + + public function nlist(string $path) { + $files = @ftp_nlist($this->connection, $path); + return array_map(function ($name) { + if (str_contains($name, '/')) { + $name = basename($name); + } + return $name; + }, $files); + } + + public function mlsd(string $path) { + $files = @ftp_mlsd($this->connection, $path); + + if ($files !== false) { + return array_map(function ($file) { + if (str_contains($file['name'], '/')) { + $file['name'] = basename($file['name']); + } + return $file; + }, $files); + } else { + // not all servers support mlsd, in those cases we parse the raw list ourselves + $rawList = @ftp_rawlist($this->connection, '-aln ' . $path); + if ($rawList === false) { + return false; + } + return $this->parseRawList($rawList, $path); + } + } + + // rawlist parsing logic is based on the ftp implementation from https://github.com/thephpleague/flysystem + private function parseRawList(array $rawList, string $directory): array { + return array_map(function ($item) use ($directory) { + return $this->parseRawListItem($item, $directory); + }, $rawList); + } + + private function parseRawListItem(string $item, string $directory): array { + $isWindows = preg_match('/^[0-9]{2,4}-[0-9]{2}-[0-9]{2}/', $item); + + return $isWindows ? $this->parseWindowsItem($item, $directory) : $this->parseUnixItem($item, $directory); + } + + private function parseUnixItem(string $item, string $directory): array { + $item = preg_replace('#\s+#', ' ', $item, 7); + + if (count(explode(' ', $item, 9)) !== 9) { + throw new \RuntimeException("Metadata can't be parsed from item '$item' , not enough parts."); + } + + [$permissions, /* $number */, /* $owner */, /* $group */, $size, $month, $day, $time, $name] = explode(' ', $item, 9); + if ($name === '.') { + $type = 'cdir'; + } elseif ($name === '..') { + $type = 'pdir'; + } else { + $type = substr($permissions, 0, 1) === 'd' ? 'dir' : 'file'; + } + + $parsedDate = (new \DateTime()) + ->setTimestamp(strtotime("$month $day $time")); + $tomorrow = (new \DateTime())->add(new \DateInterval('P1D')); + + // since the provided date doesn't include the year, we either set it to the correct year + // or when the date would otherwise be in the future (by more then 1 day to account for timezone errors) + // we use last year + if ($parsedDate > $tomorrow) { + $parsedDate = $parsedDate->sub(new \DateInterval('P1Y')); + } + + $formattedDate = $parsedDate + ->format('YmdHis'); + + return [ + 'type' => $type, + 'name' => $name, + 'modify' => $formattedDate, + 'perm' => $this->normalizePermissions($permissions), + 'size' => (int)$size, + ]; + } + + private function normalizePermissions(string $permissions) { + $isDir = substr($permissions, 0, 1) === 'd'; + // remove the type identifier and only use owner permissions + $permissions = substr($permissions, 1, 4); + + // map the string rights to the ftp counterparts + $filePermissionsMap = ['r' => 'r', 'w' => 'fadfw']; + $dirPermissionsMap = ['r' => 'e', 'w' => 'flcdmp']; + + $map = $isDir ? $dirPermissionsMap : $filePermissionsMap; + + return array_reduce(str_split($permissions), function ($ftpPermissions, $permission) use ($map) { + if (isset($map[$permission])) { + $ftpPermissions .= $map[$permission]; + } + return $ftpPermissions; + }, ''); + } + + private function parseWindowsItem(string $item, string $directory): array { + $item = preg_replace('#\s+#', ' ', trim($item), 3); + + if (count(explode(' ', $item, 4)) !== 4) { + throw new \RuntimeException("Metadata can't be parsed from item '$item' , not enough parts."); + } + + [$date, $time, $size, $name] = explode(' ', $item, 4); + + // Check for the correct date/time format + $format = strlen($date) === 8 ? 'm-d-yH:iA' : 'Y-m-dH:i'; + $formattedDate = \DateTime::createFromFormat($format, $date . $time)->format('YmdGis'); + + if ($name === '.') { + $type = 'cdir'; + } elseif ($name === '..') { + $type = 'pdir'; + } else { + $type = ($size === '<DIR>') ? 'dir' : 'file'; + } + + return [ + 'type' => $type, + 'name' => $name, + 'modify' => $formattedDate, + 'perm' => ($type === 'file') ? 'adfrw' : 'flcdmpe', + 'size' => (int)$size, + ]; + } +} diff --git a/apps/files_external/lib/Lib/Storage/OwnCloud.php b/apps/files_external/lib/Lib/Storage/OwnCloud.php new file mode 100644 index 00000000000..12c305de750 --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/OwnCloud.php @@ -0,0 +1,63 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Storage; + +use OC\Files\Storage\DAV; +use OCP\Files\Storage\IDisableEncryptionStorage; +use Sabre\DAV\Client; + +/** + * Nextcloud backend for external storage based on DAV backend. + * + * The Nextcloud URL consists of three parts: + * http://%host/%context/remote.php/webdav/%root + * + */ +class OwnCloud extends DAV implements IDisableEncryptionStorage { + public const OC_URL_SUFFIX = 'remote.php/webdav'; + + public function __construct(array $parameters) { + // extract context path from host if specified + // (owncloud install path on host) + $host = $parameters['host']; + // strip protocol + if (substr($host, 0, 8) === 'https://') { + $host = substr($host, 8); + $parameters['secure'] = true; + } elseif (substr($host, 0, 7) === 'http://') { + $host = substr($host, 7); + $parameters['secure'] = false; + } + $contextPath = ''; + $hostSlashPos = strpos($host, '/'); + if ($hostSlashPos !== false) { + $contextPath = substr($host, $hostSlashPos); + $host = substr($host, 0, $hostSlashPos); + } + + if (!str_ends_with($contextPath, '/')) { + $contextPath .= '/'; + } + + if (isset($parameters['root'])) { + $root = '/' . ltrim($parameters['root'], '/'); + } else { + $root = '/'; + } + + $parameters['host'] = $host; + $parameters['root'] = $contextPath . self::OC_URL_SUFFIX . $root; + $parameters['authType'] = Client::AUTH_BASIC; + + parent::__construct($parameters); + } + + public function needsPartFile(): bool { + return false; + } +} diff --git a/apps/files_external/lib/Lib/Storage/SFTP.php b/apps/files_external/lib/Lib/Storage/SFTP.php new file mode 100644 index 00000000000..a2f5bafcca1 --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/SFTP.php @@ -0,0 +1,523 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Storage; + +use Icewind\Streams\CallbackWrapper; +use Icewind\Streams\CountWrapper; +use Icewind\Streams\IteratorDirectory; +use Icewind\Streams\RetryWrapper; +use OC\Files\Storage\Common; +use OC\Files\View; +use OCP\Cache\CappedMemoryCache; +use OCP\Constants; +use OCP\Files\FileInfo; +use OCP\Files\IMimeTypeDetector; +use OCP\Server; +use phpseclib\Net\SFTP\Stream; + +/** + * Uses phpseclib's Net\SFTP class and the Net\SFTP\Stream stream wrapper to + * provide access to SFTP servers. + */ +class SFTP extends Common { + private $host; + private $user; + private $root; + private $port = 22; + + private $auth = []; + + /** + * @var \phpseclib\Net\SFTP + */ + protected $client; + private CappedMemoryCache $knownMTimes; + + private IMimeTypeDetector $mimeTypeDetector; + + public const COPY_CHUNK_SIZE = 8 * 1024 * 1024; + + /** + * @param string $host protocol://server:port + * @return array [$server, $port] + */ + private function splitHost(string $host): array { + $input = $host; + if (!str_contains($host, '://')) { + // add a protocol to fix parse_url behavior with ipv6 + $host = 'http://' . $host; + } + + $parsed = parse_url($host); + if (is_array($parsed) && isset($parsed['port'])) { + return [$parsed['host'], $parsed['port']]; + } elseif (is_array($parsed)) { + return [$parsed['host'], 22]; + } else { + return [$input, 22]; + } + } + + public function __construct(array $parameters) { + // Register sftp:// + Stream::register(); + + $parsedHost = $this->splitHost($parameters['host']); + + $this->host = $parsedHost[0]; + $this->port = $parsedHost[1]; + + if (!isset($parameters['user'])) { + throw new \UnexpectedValueException('no authentication parameters specified'); + } + $this->user = $parameters['user']; + + if (isset($parameters['public_key_auth'])) { + $this->auth[] = $parameters['public_key_auth']; + } + if (isset($parameters['password']) && $parameters['password'] !== '') { + $this->auth[] = $parameters['password']; + } + + if ($this->auth === []) { + throw new \UnexpectedValueException('no authentication parameters specified'); + } + + $this->root + = isset($parameters['root']) ? $this->cleanPath($parameters['root']) : '/'; + + $this->root = '/' . ltrim($this->root, '/'); + $this->root = rtrim($this->root, '/') . '/'; + + $this->knownMTimes = new CappedMemoryCache(); + + $this->mimeTypeDetector = Server::get(IMimeTypeDetector::class); + } + + /** + * Returns the connection. + * + * @return \phpseclib\Net\SFTP connected client instance + * @throws \Exception when the connection failed + */ + public function getConnection(): \phpseclib\Net\SFTP { + if (!is_null($this->client)) { + return $this->client; + } + + $hostKeys = $this->readHostKeys(); + $this->client = new \phpseclib\Net\SFTP($this->host, $this->port); + + // The SSH Host Key MUST be verified before login(). + $currentHostKey = $this->client->getServerPublicHostKey(); + if (array_key_exists($this->host, $hostKeys)) { + if ($hostKeys[$this->host] !== $currentHostKey) { + throw new \Exception('Host public key does not match known key'); + } + } else { + $hostKeys[$this->host] = $currentHostKey; + $this->writeHostKeys($hostKeys); + } + + $login = false; + foreach ($this->auth as $auth) { + /** @psalm-suppress TooManyArguments */ + $login = $this->client->login($this->user, $auth); + if ($login === true) { + break; + } + } + + if ($login === false) { + throw new \Exception('Login failed'); + } + return $this->client; + } + + public function test(): bool { + if ( + !isset($this->host) + || !isset($this->user) + ) { + return false; + } + return $this->getConnection()->nlist() !== false; + } + + public function getId(): string { + $id = 'sftp::' . $this->user . '@' . $this->host; + if ($this->port !== 22) { + $id .= ':' . $this->port; + } + // note: this will double the root slash, + // we should not change it to keep compatible with + // old storage ids + $id .= '/' . $this->root; + return $id; + } + + public function getHost(): string { + return $this->host; + } + + public function getRoot(): string { + return $this->root; + } + + public function getUser(): string { + return $this->user; + } + + private function absPath(string $path): string { + return $this->root . $this->cleanPath($path); + } + + private function hostKeysPath(): string|false { + try { + $userId = \OC_User::getUser(); + if ($userId === false) { + return false; + } + + $view = new View('/' . $userId . '/files_external'); + + return $view->getLocalFile('ssh_hostKeys'); + } catch (\Exception $e) { + } + return false; + } + + protected function writeHostKeys(array $keys): bool { + try { + $keyPath = $this->hostKeysPath(); + if ($keyPath && file_exists($keyPath)) { + $fp = fopen($keyPath, 'w'); + foreach ($keys as $host => $key) { + fwrite($fp, $host . '::' . $key . "\n"); + } + fclose($fp); + return true; + } + } catch (\Exception $e) { + } + return false; + } + + protected function readHostKeys(): array { + try { + $keyPath = $this->hostKeysPath(); + if (file_exists($keyPath)) { + $hosts = []; + $keys = []; + $lines = file($keyPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + if ($lines) { + foreach ($lines as $line) { + $hostKeyArray = explode('::', $line, 2); + if (count($hostKeyArray) === 2) { + $hosts[] = $hostKeyArray[0]; + $keys[] = $hostKeyArray[1]; + } + } + return array_combine($hosts, $keys); + } + } + } catch (\Exception $e) { + } + return []; + } + + public function mkdir(string $path): bool { + try { + return $this->getConnection()->mkdir($this->absPath($path)); + } catch (\Exception $e) { + return false; + } + } + + public function rmdir(string $path): bool { + try { + $result = $this->getConnection()->delete($this->absPath($path), true); + // workaround: stray stat cache entry when deleting empty folders + // see https://github.com/phpseclib/phpseclib/issues/706 + $this->getConnection()->clearStatCache(); + return $result; + } catch (\Exception $e) { + return false; + } + } + + public function opendir(string $path) { + try { + $list = $this->getConnection()->nlist($this->absPath($path)); + if ($list === false) { + return false; + } + + $id = md5('sftp:' . $path); + $dirStream = []; + foreach ($list as $file) { + if ($file !== '.' && $file !== '..') { + $dirStream[] = $file; + } + } + return IteratorDirectory::wrap($dirStream); + } catch (\Exception $e) { + return false; + } + } + + public function filetype(string $path): string|false { + try { + $stat = $this->getConnection()->stat($this->absPath($path)); + if (!is_array($stat) || !array_key_exists('type', $stat)) { + return false; + } + if ((int)$stat['type'] === NET_SFTP_TYPE_REGULAR) { + return 'file'; + } + + if ((int)$stat['type'] === NET_SFTP_TYPE_DIRECTORY) { + return 'dir'; + } + } catch (\Exception $e) { + } + return false; + } + + public function file_exists(string $path): bool { + try { + return $this->getConnection()->stat($this->absPath($path)) !== false; + } catch (\Exception $e) { + return false; + } + } + + public function unlink(string $path): bool { + try { + return $this->getConnection()->delete($this->absPath($path), true); + } catch (\Exception $e) { + return false; + } + } + + public function fopen(string $path, string $mode) { + $path = $this->cleanPath($path); + try { + $absPath = $this->absPath($path); + $connection = $this->getConnection(); + switch ($mode) { + case 'r': + case 'rb': + $stat = $this->stat($path); + if (!$stat) { + return false; + } + SFTPReadStream::register(); + $context = stream_context_create(['sftp' => ['session' => $connection, 'size' => $stat['size']]]); + $handle = fopen('sftpread://' . trim($absPath, '/'), 'r', false, $context); + return RetryWrapper::wrap($handle); + case 'w': + case 'wb': + SFTPWriteStream::register(); + // the SFTPWriteStream doesn't go through the "normal" methods so it doesn't clear the stat cache. + $connection->_remove_from_stat_cache($absPath); + $context = stream_context_create(['sftp' => ['session' => $connection]]); + $fh = fopen('sftpwrite://' . trim($absPath, '/'), 'w', false, $context); + if ($fh) { + $fh = CallbackWrapper::wrap($fh, null, null, function () use ($path): void { + $this->knownMTimes->set($path, time()); + }); + } + return $fh; + case 'a': + case 'ab': + case 'r+': + case 'w+': + case 'wb+': + case 'a+': + case 'x': + case 'x+': + case 'c': + case 'c+': + $context = stream_context_create(['sftp' => ['session' => $connection]]); + $handle = fopen($this->constructUrl($path), $mode, false, $context); + return RetryWrapper::wrap($handle); + } + } catch (\Exception $e) { + } + return false; + } + + public function touch(string $path, ?int $mtime = null): bool { + try { + if (!is_null($mtime)) { + return false; + } + if (!$this->file_exists($path)) { + return $this->getConnection()->put($this->absPath($path), ''); + } else { + return false; + } + } catch (\Exception $e) { + return false; + } + } + + /** + * @throws \Exception + */ + public function getFile(string $path, string $target): void { + $this->getConnection()->get($path, $target); + } + + public function rename(string $source, string $target): bool { + try { + if ($this->file_exists($target)) { + $this->unlink($target); + } + return $this->getConnection()->rename( + $this->absPath($source), + $this->absPath($target) + ); + } catch (\Exception $e) { + return false; + } + } + + /** + * @return array{mtime: int, size: int, ctime: int}|false + */ + public function stat(string $path): array|false { + try { + $path = $this->cleanPath($path); + $stat = $this->getConnection()->stat($this->absPath($path)); + + $mtime = isset($stat['mtime']) ? (int)$stat['mtime'] : -1; + $size = isset($stat['size']) ? (int)$stat['size'] : 0; + + // the mtime can't be less than when we last touched it + if ($knownMTime = $this->knownMTimes->get($path)) { + $mtime = max($mtime, $knownMTime); + } + + return [ + 'mtime' => $mtime, + 'size' => $size, + 'ctime' => -1 + ]; + } catch (\Exception $e) { + return false; + } + } + + public function constructUrl(string $path): string { + // Do not pass the password here. We want to use the Net_SFTP object + // supplied via stream context or fail. We only supply username and + // hostname because this might show up in logs (they are not used). + $url = 'sftp://' . urlencode($this->user) . '@' . $this->host . ':' . $this->port . $this->root . $path; + return $url; + } + + public function file_put_contents(string $path, mixed $data): int|float|false { + /** @psalm-suppress InternalMethod */ + $result = $this->getConnection()->put($this->absPath($path), $data); + if ($result) { + return strlen($data); + } else { + return false; + } + } + + public function writeStream(string $path, $stream, ?int $size = null): int { + if ($size === null) { + $stream = CountWrapper::wrap($stream, function (int $writtenSize) use (&$size): void { + $size = $writtenSize; + }); + if (!$stream) { + throw new \Exception('Failed to wrap stream'); + } + } + /** @psalm-suppress InternalMethod */ + $result = $this->getConnection()->put($this->absPath($path), $stream); + fclose($stream); + if ($result) { + if ($size === null) { + throw new \Exception('Failed to get written size from sftp storage wrapper'); + } + return $size; + } else { + throw new \Exception('Failed to write steam to sftp storage'); + } + } + + public function copy(string $source, string $target): bool { + if ($this->is_dir($source) || $this->is_dir($target)) { + return parent::copy($source, $target); + } else { + $absSource = $this->absPath($source); + $absTarget = $this->absPath($target); + + $connection = $this->getConnection(); + $size = $connection->size($absSource); + if ($size === false) { + return false; + } + for ($i = 0; $i < $size; $i += self::COPY_CHUNK_SIZE) { + /** @psalm-suppress InvalidArgument */ + $chunk = $connection->get($absSource, false, $i, self::COPY_CHUNK_SIZE); + if ($chunk === false) { + return false; + } + /** @psalm-suppress InternalMethod */ + if (!$connection->put($absTarget, $chunk, \phpseclib\Net\SFTP::SOURCE_STRING, $i)) { + return false; + } + } + return true; + } + } + + public function getPermissions(string $path): int { + $stat = $this->getConnection()->stat($this->absPath($path)); + if (!$stat) { + return 0; + } + if ($stat['type'] === NET_SFTP_TYPE_DIRECTORY) { + return Constants::PERMISSION_ALL; + } else { + return Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE; + } + } + + public function getMetaData(string $path): ?array { + $stat = $this->getConnection()->stat($this->absPath($path)); + if (!$stat) { + return null; + } + + if ($stat['type'] === NET_SFTP_TYPE_DIRECTORY) { + $stat['permissions'] = Constants::PERMISSION_ALL; + } else { + $stat['permissions'] = Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE; + } + + if ($stat['type'] === NET_SFTP_TYPE_DIRECTORY) { + $stat['size'] = -1; + $stat['mimetype'] = FileInfo::MIMETYPE_FOLDER; + } else { + $stat['mimetype'] = $this->mimeTypeDetector->detectPath($path); + } + + $stat['etag'] = $this->getETag($path); + $stat['storage_mtime'] = $stat['mtime']; + $stat['name'] = basename($path); + + $keys = ['size', 'mtime', 'mimetype', 'etag', 'storage_mtime', 'permissions', 'name']; + return array_intersect_key($stat, array_flip($keys)); + } +} diff --git a/apps/files_external/lib/Lib/Storage/SFTPReadStream.php b/apps/files_external/lib/Lib/Storage/SFTPReadStream.php new file mode 100644 index 00000000000..7dedbd7035a --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/SFTPReadStream.php @@ -0,0 +1,217 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_External\Lib\Storage; + +use Icewind\Streams\File; +use phpseclib\Net\SSH2; + +class SFTPReadStream implements File { + /** @var resource */ + public $context; + + /** @var \phpseclib\Net\SFTP */ + private $sftp; + + /** @var string */ + private $handle; + + /** @var int */ + private $internalPosition = 0; + + /** @var int */ + private $readPosition = 0; + + /** @var bool */ + private $eof = false; + + private $buffer = ''; + private bool $pendingRead = false; + private int $size = 0; + + public static function register($protocol = 'sftpread') { + if (in_array($protocol, stream_get_wrappers(), true)) { + return false; + } + return stream_wrapper_register($protocol, get_called_class()); + } + + /** + * Load the source from the stream context and return the context options + * + * @throws \BadMethodCallException + */ + protected function loadContext(string $name) { + $context = stream_context_get_options($this->context); + if (isset($context[$name])) { + $context = $context[$name]; + } else { + throw new \BadMethodCallException('Invalid context, "' . $name . '" options not set'); + } + if (isset($context['session']) and $context['session'] instanceof \phpseclib\Net\SFTP) { + $this->sftp = $context['session']; + } else { + throw new \BadMethodCallException('Invalid context, session not set'); + } + if (isset($context['size'])) { + $this->size = $context['size']; + } + return $context; + } + + public function stream_open($path, $mode, $options, &$opened_path) { + [, $path] = explode('://', $path); + $path = '/' . ltrim($path); + $path = str_replace('//', '/', $path); + + $this->loadContext('sftp'); + + if (!($this->sftp->bitmap & SSH2::MASK_LOGIN)) { + return false; + } + + $remote_file = $this->sftp->_realpath($path); + if ($remote_file === false) { + return false; + } + + $packet = pack('Na*N2', strlen($remote_file), $remote_file, NET_SFTP_OPEN_READ, 0); + if (!$this->sftp->_send_sftp_packet(NET_SFTP_OPEN, $packet)) { + return false; + } + + $response = $this->sftp->_get_sftp_packet(); + switch ($this->sftp->packet_type) { + case NET_SFTP_HANDLE: + $this->handle = substr($response, 4); + break; + case NET_SFTP_STATUS: // presumably SSH_FX_NO_SUCH_FILE or SSH_FX_PERMISSION_DENIED + $this->sftp->_logError($response); + return false; + default: + user_error('Expected SSH_FXP_HANDLE or SSH_FXP_STATUS'); + return false; + } + + $this->request_chunk(256 * 1024); + + return true; + } + + public function stream_seek($offset, $whence = SEEK_SET) { + switch ($whence) { + case SEEK_SET: + $this->seekTo($offset); + break; + case SEEK_CUR: + $this->seekTo($this->readPosition + $offset); + break; + case SEEK_END: + $this->seekTo($this->size + $offset); + break; + } + return true; + } + + private function seekTo(int $offset): void { + $this->internalPosition = $offset; + $this->readPosition = $offset; + $this->buffer = ''; + $this->request_chunk(256 * 1024); + } + + public function stream_tell() { + return $this->readPosition; + } + + public function stream_read($count) { + if (!$this->eof && strlen($this->buffer) < $count) { + $chunk = $this->read_chunk(); + $this->buffer .= $chunk; + if (!$this->eof) { + $this->request_chunk(256 * 1024); + } + } + + $data = substr($this->buffer, 0, $count); + $this->buffer = substr($this->buffer, $count); + $this->readPosition += strlen($data); + + return $data; + } + + private function request_chunk(int $size) { + if ($this->pendingRead) { + $this->sftp->_get_sftp_packet(); + } + + $packet = pack('Na*N3', strlen($this->handle), $this->handle, $this->internalPosition / 4294967296, $this->internalPosition, $size); + $this->pendingRead = true; + return $this->sftp->_send_sftp_packet(NET_SFTP_READ, $packet); + } + + private function read_chunk() { + $this->pendingRead = false; + $response = $this->sftp->_get_sftp_packet(); + + switch ($this->sftp->packet_type) { + case NET_SFTP_DATA: + $temp = substr($response, 4); + $len = strlen($temp); + $this->internalPosition += $len; + return $temp; + case NET_SFTP_STATUS: + [1 => $status] = unpack('N', substr($response, 0, 4)); + if ($status == NET_SFTP_STATUS_EOF) { + $this->eof = true; + } + return ''; + default: + return ''; + } + } + + public function stream_write($data) { + return false; + } + + public function stream_set_option($option, $arg1, $arg2) { + return false; + } + + public function stream_truncate($size) { + return false; + } + + public function stream_stat() { + return false; + } + + public function stream_lock($operation) { + return false; + } + + public function stream_flush() { + return false; + } + + public function stream_eof() { + return $this->eof; + } + + public function stream_close() { + // we still have a read request incoming that needs to be handled before we can close + if ($this->pendingRead) { + $this->sftp->_get_sftp_packet(); + } + if (!$this->sftp->_close_handle($this->handle)) { + return false; + } + return true; + } +} diff --git a/apps/files_external/lib/Lib/Storage/SFTPWriteStream.php b/apps/files_external/lib/Lib/Storage/SFTPWriteStream.php new file mode 100644 index 00000000000..d64e89b5462 --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/SFTPWriteStream.php @@ -0,0 +1,165 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_External\Lib\Storage; + +use Icewind\Streams\File; +use phpseclib\Net\SSH2; + +class SFTPWriteStream implements File { + /** @var resource */ + public $context; + + /** @var \phpseclib\Net\SFTP */ + private $sftp; + + /** @var string */ + private $handle; + + /** @var int */ + private $internalPosition = 0; + + /** @var int */ + private $writePosition = 0; + + /** @var bool */ + private $eof = false; + + private $buffer = ''; + + public static function register($protocol = 'sftpwrite') { + if (in_array($protocol, stream_get_wrappers(), true)) { + return false; + } + return stream_wrapper_register($protocol, get_called_class()); + } + + /** + * Load the source from the stream context and return the context options + * + * @throws \BadMethodCallException + */ + protected function loadContext(string $name) { + $context = stream_context_get_options($this->context); + if (isset($context[$name])) { + $context = $context[$name]; + } else { + throw new \BadMethodCallException('Invalid context, "' . $name . '" options not set'); + } + if (isset($context['session']) and $context['session'] instanceof \phpseclib\Net\SFTP) { + $this->sftp = $context['session']; + } else { + throw new \BadMethodCallException('Invalid context, session not set'); + } + return $context; + } + + public function stream_open($path, $mode, $options, &$opened_path) { + [, $path] = explode('://', $path); + $path = '/' . ltrim($path); + $path = str_replace('//', '/', $path); + + $this->loadContext('sftp'); + + if (!($this->sftp->bitmap & SSH2::MASK_LOGIN)) { + return false; + } + + $remote_file = $this->sftp->_realpath($path); + if ($remote_file === false) { + return false; + } + + $packet = pack('Na*N2', strlen($remote_file), $remote_file, NET_SFTP_OPEN_WRITE | NET_SFTP_OPEN_CREATE | NET_SFTP_OPEN_TRUNCATE, 0); + if (!$this->sftp->_send_sftp_packet(NET_SFTP_OPEN, $packet)) { + return false; + } + + $response = $this->sftp->_get_sftp_packet(); + switch ($this->sftp->packet_type) { + case NET_SFTP_HANDLE: + $this->handle = substr($response, 4); + break; + case NET_SFTP_STATUS: // presumably SSH_FX_NO_SUCH_FILE or SSH_FX_PERMISSION_DENIED + $this->sftp->_logError($response); + return false; + default: + user_error('Expected SSH_FXP_HANDLE or SSH_FXP_STATUS'); + return false; + } + + return true; + } + + public function stream_seek($offset, $whence = SEEK_SET) { + return false; + } + + public function stream_tell() { + return $this->writePosition; + } + + public function stream_read($count) { + return false; + } + + public function stream_write($data) { + $written = strlen($data); + $this->writePosition += $written; + + $this->buffer .= $data; + + if (strlen($this->buffer) > 64 * 1024) { + if (!$this->stream_flush()) { + return false; + } + } + + return $written; + } + + public function stream_set_option($option, $arg1, $arg2) { + return false; + } + + public function stream_truncate($size) { + return false; + } + + public function stream_stat() { + return false; + } + + public function stream_lock($operation) { + return false; + } + + public function stream_flush() { + $size = strlen($this->buffer); + $packet = pack('Na*N3a*', strlen($this->handle), $this->handle, $this->internalPosition / 4294967296, $this->internalPosition, $size, $this->buffer); + if (!$this->sftp->_send_sftp_packet(NET_SFTP_WRITE, $packet)) { + return false; + } + $this->internalPosition += $size; + $this->buffer = ''; + + return $this->sftp->_read_put_responses(1); + } + + public function stream_eof() { + return $this->eof; + } + + public function stream_close() { + $this->stream_flush(); + if (!$this->sftp->_close_handle($this->handle)) { + return false; + } + return true; + } +} diff --git a/apps/files_external/lib/Lib/Storage/SMB.php b/apps/files_external/lib/Lib/Storage/SMB.php new file mode 100644 index 00000000000..8f8750864e1 --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/SMB.php @@ -0,0 +1,727 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\Files_External\Lib\Storage; + +use Icewind\SMB\ACL; +use Icewind\SMB\BasicAuth; +use Icewind\SMB\Exception\AlreadyExistsException; +use Icewind\SMB\Exception\ConnectException; +use Icewind\SMB\Exception\Exception; +use Icewind\SMB\Exception\ForbiddenException; +use Icewind\SMB\Exception\InvalidArgumentException; +use Icewind\SMB\Exception\InvalidTypeException; +use Icewind\SMB\Exception\NotFoundException; +use Icewind\SMB\Exception\OutOfSpaceException; +use Icewind\SMB\Exception\TimedOutException; +use Icewind\SMB\IFileInfo; +use Icewind\SMB\Native\NativeServer; +use Icewind\SMB\Options; +use Icewind\SMB\ServerFactory; +use Icewind\SMB\Wrapped\Server; +use Icewind\Streams\CallbackWrapper; +use Icewind\Streams\IteratorDirectory; +use OC\Files\Filesystem; +use OC\Files\Storage\Common; +use OCA\Files_External\Lib\Notify\SMBNotifyHandler; +use OCP\Cache\CappedMemoryCache; +use OCP\Constants; +use OCP\Files\EntityTooLargeException; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\Notify\IChange; +use OCP\Files\Notify\IRenameChange; +use OCP\Files\NotPermittedException; +use OCP\Files\Storage\INotifyStorage; +use OCP\Files\StorageAuthException; +use OCP\Files\StorageNotAvailableException; +use OCP\ITempManager; +use Psr\Log\LoggerInterface; + +class SMB extends Common implements INotifyStorage { + /** + * @var \Icewind\SMB\IServer + */ + protected $server; + + /** + * @var \Icewind\SMB\IShare + */ + protected $share; + + /** + * @var string + */ + protected $root; + + /** @var CappedMemoryCache<IFileInfo> */ + protected CappedMemoryCache $statCache; + + /** @var LoggerInterface */ + protected $logger; + + /** @var bool */ + protected $showHidden; + + private bool $caseSensitive; + + /** @var bool */ + protected $checkAcl; + + public function __construct(array $parameters) { + if (!isset($parameters['host'])) { + throw new \Exception('Invalid configuration, no host provided'); + } + + if (isset($parameters['auth'])) { + $auth = $parameters['auth']; + } elseif (isset($parameters['user']) && isset($parameters['password']) && isset($parameters['share'])) { + [$workgroup, $user] = $this->splitUser($parameters['user']); + $auth = new BasicAuth($user, $workgroup, $parameters['password']); + } else { + throw new \Exception('Invalid configuration, no credentials provided'); + } + + if (isset($parameters['logger'])) { + if (!$parameters['logger'] instanceof LoggerInterface) { + throw new \Exception( + 'Invalid logger. Got ' + . get_class($parameters['logger']) + . ' Expected ' . LoggerInterface::class + ); + } + $this->logger = $parameters['logger']; + } else { + $this->logger = \OCP\Server::get(LoggerInterface::class); + } + + $options = new Options(); + if (isset($parameters['timeout'])) { + $timeout = (int)$parameters['timeout']; + if ($timeout > 0) { + $options->setTimeout($timeout); + } + } + $system = \OCP\Server::get(SystemBridge::class); + $serverFactory = new ServerFactory($options, $system); + $this->server = $serverFactory->createServer($parameters['host'], $auth); + $this->share = $this->server->getShare(trim($parameters['share'], '/')); + + $this->root = $parameters['root'] ?? '/'; + $this->root = '/' . ltrim($this->root, '/'); + $this->root = rtrim($this->root, '/') . '/'; + + $this->showHidden = isset($parameters['show_hidden']) && $parameters['show_hidden']; + $this->caseSensitive = (bool)($parameters['case_sensitive'] ?? true); + $this->checkAcl = isset($parameters['check_acl']) && $parameters['check_acl']; + + $this->statCache = new CappedMemoryCache(); + parent::__construct($parameters); + } + + private function splitUser(string $user): array { + if (str_contains($user, '/')) { + return explode('/', $user, 2); + } elseif (str_contains($user, '\\')) { + return explode('\\', $user); + } + + return [null, $user]; + } + + public function getId(): string { + // FIXME: double slash to keep compatible with the old storage ids, + // failure to do so will lead to creation of a new storage id and + // loss of shares from the storage + return 'smb::' . $this->server->getAuth()->getUsername() . '@' . $this->server->getHost() . '//' . $this->share->getName() . '/' . $this->root; + } + + protected function buildPath(string $path): string { + return Filesystem::normalizePath($this->root . '/' . $path, true, false, true); + } + + protected function relativePath(string $fullPath): ?string { + if ($fullPath === $this->root) { + return ''; + } elseif (substr($fullPath, 0, strlen($this->root)) === $this->root) { + return substr($fullPath, strlen($this->root)); + } else { + return null; + } + } + + /** + * @throws StorageAuthException + * @throws \OCP\Files\NotFoundException + * @throws \OCP\Files\ForbiddenException + */ + protected function getFileInfo(string $path): IFileInfo { + try { + $path = $this->buildPath($path); + $cached = $this->statCache[$path] ?? null; + if ($cached instanceof IFileInfo) { + return $cached; + } else { + $stat = $this->share->stat($path); + $this->statCache[$path] = $stat; + return $stat; + } + } catch (ConnectException $e) { + $this->throwUnavailable($e); + } catch (NotFoundException $e) { + throw new \OCP\Files\NotFoundException($e->getMessage(), 0, $e); + } catch (ForbiddenException $e) { + // with php-smbclient, this exception is thrown when the provided password is invalid. + // Possible is also ForbiddenException with a different error code, so we check it. + if ($e->getCode() === 1) { + $this->throwUnavailable($e); + } + throw new \OCP\Files\ForbiddenException($e->getMessage(), false, $e); + } + } + + /** + * @throws StorageAuthException + */ + protected function throwUnavailable(\Exception $e): never { + $this->logger->error('Error while getting file info', ['exception' => $e]); + throw new StorageAuthException($e->getMessage(), $e); + } + + /** + * get the acl from fileinfo that is relevant for the configured user + */ + private function getACL(IFileInfo $file): ?ACL { + try { + $acls = $file->getAcls(); + } catch (Exception $e) { + $this->logger->warning('Error while getting file acls', ['exception' => $e]); + return null; + } + foreach ($acls as $user => $acl) { + [, $user] = $this->splitUser($user); // strip domain + if ($user === $this->server->getAuth()->getUsername()) { + return $acl; + } + } + + return null; + } + + /** + * @return \Generator<IFileInfo> + * @throws StorageNotAvailableException + */ + protected function getFolderContents(string $path): iterable { + try { + $path = ltrim($this->buildPath($path), '/'); + try { + $files = $this->share->dir($path); + } catch (ForbiddenException $e) { + $this->logger->critical($e->getMessage(), ['exception' => $e]); + throw new NotPermittedException(); + } catch (InvalidTypeException $e) { + return; + } + foreach ($files as $file) { + $this->statCache[$path . '/' . $file->getName()] = $file; + } + + foreach ($files as $file) { + try { + // the isHidden check is done before checking the config boolean to ensure that the metadata is always fetch + // so we trigger the below exceptions where applicable + $hide = $file->isHidden() && !$this->showHidden; + + if ($this->checkAcl && $acl = $this->getACL($file)) { + // if there is no explicit deny, we assume it's allowed + // this doesn't take inheritance fully into account but if read permissions is denied for a parent we wouldn't be in this folder + // additionally, it's better to have false negatives here then false positives + if ($acl->denies(ACL::MASK_READ) || $acl->denies(ACL::MASK_EXECUTE)) { + $this->logger->debug('Hiding non readable entry ' . $file->getName()); + continue; + } + } + + if ($hide) { + $this->logger->debug('hiding hidden file ' . $file->getName()); + } + if (!$hide) { + yield $file; + } + } catch (ForbiddenException $e) { + $this->logger->debug($e->getMessage(), ['exception' => $e]); + } catch (NotFoundException $e) { + $this->logger->debug('Hiding forbidden entry ' . $file->getName(), ['exception' => $e]); + } + } + } catch (ConnectException $e) { + $this->logger->error('Error while getting folder content', ['exception' => $e]); + throw new StorageNotAvailableException($e->getMessage(), (int)$e->getCode(), $e); + } catch (NotFoundException $e) { + throw new \OCP\Files\NotFoundException($e->getMessage(), 0, $e); + } + } + + protected function formatInfo(IFileInfo $info): array { + $result = [ + 'size' => $info->getSize(), + 'mtime' => $info->getMTime(), + ]; + if ($info->isDirectory()) { + $result['type'] = 'dir'; + } else { + $result['type'] = 'file'; + } + return $result; + } + + /** + * Rename the files. If the source or the target is the root, the rename won't happen. + * + * @param string $source the old name of the path + * @param string $target the new name of the path + */ + public function rename(string $source, string $target, bool $retry = true): bool { + if ($this->isRootDir($source) || $this->isRootDir($target)) { + return false; + } + if ($this->caseSensitive === false + && mb_strtolower($target) === mb_strtolower($source) + ) { + // Forbid changing case only on case-insensitive file system + return false; + } + + $absoluteSource = $this->buildPath($source); + $absoluteTarget = $this->buildPath($target); + try { + $result = $this->share->rename($absoluteSource, $absoluteTarget); + } catch (AlreadyExistsException $e) { + if ($retry) { + $this->remove($target); + $result = $this->share->rename($absoluteSource, $absoluteTarget); + } else { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return false; + } + } catch (InvalidArgumentException $e) { + if ($retry) { + $this->remove($target); + $result = $this->share->rename($absoluteSource, $absoluteTarget); + } else { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return false; + } + } catch (\Exception $e) { + $this->logger->warning($e->getMessage(), ['exception' => $e]); + return false; + } + unset($this->statCache[$absoluteSource], $this->statCache[$absoluteTarget]); + return $result; + } + + public function stat(string $path, bool $retry = true): array|false { + try { + $result = $this->formatInfo($this->getFileInfo($path)); + } catch (\OCP\Files\ForbiddenException $e) { + return false; + } catch (\OCP\Files\NotFoundException $e) { + return false; + } catch (TimedOutException $e) { + if ($retry) { + return $this->stat($path, false); + } else { + throw new StorageNotAvailableException($e->getMessage(), $e->getCode(), $e); + } + } + if ($this->remoteIsShare() && $this->isRootDir($path)) { + $result['mtime'] = $this->shareMTime(); + } + return $result; + } + + /** + * get the best guess for the modification time of the share + */ + private function shareMTime(): int { + $highestMTime = 0; + $files = $this->share->dir($this->root); + foreach ($files as $fileInfo) { + try { + if ($fileInfo->getMTime() > $highestMTime) { + $highestMTime = $fileInfo->getMTime(); + } + } catch (NotFoundException $e) { + // Ignore this, can happen on unavailable DFS shares + } catch (ForbiddenException $e) { + // Ignore this too - it's a symlink + } + } + return $highestMTime; + } + + /** + * Check if the path is our root dir (not the smb one) + */ + private function isRootDir(string $path): bool { + return $path === '' || $path === '/' || $path === '.'; + } + + /** + * Check if our root points to a smb share + */ + private function remoteIsShare(): bool { + return $this->share->getName() && (!$this->root || $this->root === '/'); + } + + public function unlink(string $path): bool { + if ($this->isRootDir($path)) { + return false; + } + + try { + if ($this->is_dir($path)) { + return $this->rmdir($path); + } else { + $path = $this->buildPath($path); + unset($this->statCache[$path]); + $this->share->del($path); + return true; + } + } catch (NotFoundException $e) { + return false; + } catch (ForbiddenException $e) { + return false; + } catch (ConnectException $e) { + $this->logger->error('Error while deleting file', ['exception' => $e]); + throw new StorageNotAvailableException($e->getMessage(), (int)$e->getCode(), $e); + } + } + + /** + * check if a file or folder has been updated since $time + */ + public function hasUpdated(string $path, int $time): bool { + if (!$path and $this->root === '/') { + // mtime doesn't work for shares, but giving the nature of the backend, + // doing a full update is still just fast enough + return true; + } else { + $actualTime = $this->filemtime($path); + return $actualTime > $time || $actualTime === 0; + } + } + + /** + * @return resource|false + */ + public function fopen(string $path, string $mode) { + $fullPath = $this->buildPath($path); + try { + switch ($mode) { + case 'r': + case 'rb': + if (!$this->file_exists($path)) { + $this->logger->warning('Failed to open ' . $path . ' on ' . $this->getId() . ', file doesn\'t exist.'); + return false; + } + return $this->share->read($fullPath); + case 'w': + case 'wb': + $source = $this->share->write($fullPath); + return CallBackWrapper::wrap($source, null, null, function () use ($fullPath): void { + unset($this->statCache[$fullPath]); + }); + case 'a': + case 'ab': + case 'r+': + case 'w+': + case 'wb+': + case 'a+': + case 'x': + case 'x+': + case 'c': + case 'c+': + //emulate these + if (strrpos($path, '.') !== false) { + $ext = substr($path, strrpos($path, '.')); + } else { + $ext = ''; + } + if ($this->file_exists($path)) { + if (!$this->isUpdatable($path)) { + $this->logger->warning('Failed to open ' . $path . ' on ' . $this->getId() . ', file not updatable.'); + return false; + } + $tmpFile = $this->getCachedFile($path); + } else { + if (!$this->isCreatable(dirname($path))) { + $this->logger->warning('Failed to open ' . $path . ' on ' . $this->getId() . ', parent directory not writable.'); + return false; + } + $tmpFile = \OCP\Server::get(ITempManager::class)->getTemporaryFile($ext); + } + $source = fopen($tmpFile, $mode); + $share = $this->share; + return CallbackWrapper::wrap($source, null, null, function () use ($tmpFile, $fullPath, $share): void { + unset($this->statCache[$fullPath]); + $share->put($tmpFile, $fullPath); + unlink($tmpFile); + }); + } + return false; + } catch (NotFoundException $e) { + $this->logger->warning('Failed to open ' . $path . ' on ' . $this->getId() . ', not found.', ['exception' => $e]); + return false; + } catch (ForbiddenException $e) { + $this->logger->warning('Failed to open ' . $path . ' on ' . $this->getId() . ', forbidden.', ['exception' => $e]); + return false; + } catch (OutOfSpaceException $e) { + $this->logger->warning('Failed to open ' . $path . ' on ' . $this->getId() . ', out of space.', ['exception' => $e]); + throw new EntityTooLargeException('not enough available space to create file', 0, $e); + } catch (ConnectException $e) { + $this->logger->error('Error while opening file ' . $path . ' on ' . $this->getId(), ['exception' => $e]); + throw new StorageNotAvailableException($e->getMessage(), (int)$e->getCode(), $e); + } + } + + public function rmdir(string $path): bool { + if ($this->isRootDir($path)) { + return false; + } + + try { + $this->statCache = new CappedMemoryCache(); + $content = $this->share->dir($this->buildPath($path)); + foreach ($content as $file) { + if ($file->isDirectory()) { + $this->rmdir($path . '/' . $file->getName()); + } else { + $this->share->del($file->getPath()); + } + } + $this->share->rmdir($this->buildPath($path)); + return true; + } catch (NotFoundException $e) { + return false; + } catch (ForbiddenException $e) { + return false; + } catch (ConnectException $e) { + $this->logger->error('Error while removing folder', ['exception' => $e]); + throw new StorageNotAvailableException($e->getMessage(), (int)$e->getCode(), $e); + } + } + + public function touch(string $path, ?int $mtime = null): bool { + try { + if (!$this->file_exists($path)) { + $fh = $this->share->write($this->buildPath($path)); + fclose($fh); + return true; + } + return false; + } catch (OutOfSpaceException $e) { + throw new EntityTooLargeException('not enough available space to create file', 0, $e); + } catch (ConnectException $e) { + $this->logger->error('Error while creating file', ['exception' => $e]); + throw new StorageNotAvailableException($e->getMessage(), (int)$e->getCode(), $e); + } + } + + public function getMetaData(string $path): ?array { + try { + $fileInfo = $this->getFileInfo($path); + } catch (\OCP\Files\NotFoundException $e) { + return null; + } catch (\OCP\Files\ForbiddenException $e) { + return null; + } + + return $this->getMetaDataFromFileInfo($fileInfo); + } + + private function getMetaDataFromFileInfo(IFileInfo $fileInfo): array { + $permissions = Constants::PERMISSION_READ + Constants::PERMISSION_SHARE; + + if ( + !$fileInfo->isReadOnly() || $fileInfo->isDirectory() + ) { + $permissions += Constants::PERMISSION_DELETE; + $permissions += Constants::PERMISSION_UPDATE; + if ($fileInfo->isDirectory()) { + $permissions += Constants::PERMISSION_CREATE; + } + } + + $data = []; + if ($fileInfo->isDirectory()) { + $data['mimetype'] = 'httpd/unix-directory'; + } else { + $data['mimetype'] = \OCP\Server::get(IMimeTypeDetector::class)->detectPath($fileInfo->getPath()); + } + $data['mtime'] = $fileInfo->getMTime(); + if ($fileInfo->isDirectory()) { + $data['size'] = -1; //unknown + } else { + $data['size'] = $fileInfo->getSize(); + } + $data['etag'] = $this->getETag($fileInfo->getPath()); + $data['storage_mtime'] = $data['mtime']; + $data['permissions'] = $permissions; + $data['name'] = $fileInfo->getName(); + + return $data; + } + + public function opendir(string $path) { + try { + $files = $this->getFolderContents($path); + } catch (NotFoundException $e) { + return false; + } catch (NotPermittedException $e) { + return false; + } + $names = array_map(function ($info) { + /** @var IFileInfo $info */ + return $info->getName(); + }, iterator_to_array($files)); + return IteratorDirectory::wrap($names); + } + + public function getDirectoryContent(string $directory): \Traversable { + try { + $files = $this->getFolderContents($directory); + foreach ($files as $file) { + yield $this->getMetaDataFromFileInfo($file); + } + } catch (NotFoundException $e) { + return; + } catch (NotPermittedException $e) { + return; + } + } + + public function filetype(string $path): string|false { + try { + return $this->getFileInfo($path)->isDirectory() ? 'dir' : 'file'; + } catch (\OCP\Files\NotFoundException $e) { + return false; + } catch (\OCP\Files\ForbiddenException $e) { + return false; + } + } + + public function mkdir(string $path): bool { + $path = $this->buildPath($path); + try { + $this->share->mkdir($path); + return true; + } catch (ConnectException $e) { + $this->logger->error('Error while creating folder', ['exception' => $e]); + throw new StorageNotAvailableException($e->getMessage(), (int)$e->getCode(), $e); + } catch (Exception $e) { + return false; + } + } + + public function file_exists(string $path): bool { + try { + // Case sensitive filesystem doesn't matter for root directory + if ($this->caseSensitive === false && $path !== '') { + $filename = basename($path); + $siblings = $this->getDirectoryContent(dirname($path)); + foreach ($siblings as $sibling) { + if ($sibling['name'] === $filename) { + return true; + } + } + return false; + } + $this->getFileInfo($path); + return true; + } catch (\OCP\Files\NotFoundException $e) { + return false; + } catch (\OCP\Files\ForbiddenException $e) { + return false; + } catch (ConnectException $e) { + throw new StorageNotAvailableException($e->getMessage(), (int)$e->getCode(), $e); + } + } + + public function isReadable(string $path): bool { + try { + $info = $this->getFileInfo($path); + return $this->showHidden || !$info->isHidden(); + } catch (\OCP\Files\NotFoundException $e) { + return false; + } catch (\OCP\Files\ForbiddenException $e) { + return false; + } + } + + public function isUpdatable(string $path): bool { + try { + $info = $this->getFileInfo($path); + // following windows behaviour for read-only folders: they can be written into + // (https://support.microsoft.com/en-us/kb/326549 - "cause" section) + return ($this->showHidden || !$info->isHidden()) && (!$info->isReadOnly() || $info->isDirectory()); + } catch (\OCP\Files\NotFoundException $e) { + return false; + } catch (\OCP\Files\ForbiddenException $e) { + return false; + } + } + + public function isDeletable(string $path): bool { + try { + $info = $this->getFileInfo($path); + return ($this->showHidden || !$info->isHidden()) && !$info->isReadOnly(); + } catch (\OCP\Files\NotFoundException $e) { + return false; + } catch (\OCP\Files\ForbiddenException $e) { + return false; + } + } + + /** + * check if smbclient is installed + */ + public static function checkDependencies(): array|bool { + $system = \OCP\Server::get(SystemBridge::class); + return Server::available($system) || NativeServer::available($system) ?: ['smbclient']; + } + + public function test(): bool { + try { + return parent::test(); + } catch (StorageAuthException $e) { + return false; + } catch (ForbiddenException $e) { + return false; + } catch (Exception $e) { + $this->logger->error($e->getMessage(), ['exception' => $e]); + return false; + } + } + + public function listen(string $path, callable $callback): void { + $this->notify($path)->listen(function (IChange $change) use ($callback) { + if ($change instanceof IRenameChange) { + return $callback($change->getType(), $change->getPath(), $change->getTargetPath()); + } else { + return $callback($change->getType(), $change->getPath()); + } + }); + } + + public function notify(string $path): SMBNotifyHandler { + $path = '/' . ltrim($path, '/'); + $shareNotifyHandler = $this->share->notify($this->buildPath($path)); + return new SMBNotifyHandler($shareNotifyHandler, $this->root); + } +} diff --git a/apps/files_external/lib/Lib/Storage/StreamWrapper.php b/apps/files_external/lib/Lib/Storage/StreamWrapper.php new file mode 100644 index 00000000000..1272b9d4d8a --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/StreamWrapper.php @@ -0,0 +1,99 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2020-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Storage; + +use OC\Files\Storage\Common; + +abstract class StreamWrapper extends Common { + + abstract public function constructUrl(string $path): ?string; + + public function mkdir(string $path): bool { + return mkdir($this->constructUrl($path)); + } + + public function rmdir(string $path): bool { + if ($this->is_dir($path) && $this->isDeletable($path)) { + $dh = $this->opendir($path); + if (!is_resource($dh)) { + return false; + } + while (($file = readdir($dh)) !== false) { + if ($this->is_dir($path . '/' . $file)) { + $this->rmdir($path . '/' . $file); + } else { + $this->unlink($path . '/' . $file); + } + } + $url = $this->constructUrl($path); + $success = rmdir($url); + clearstatcache(false, $url); + return $success; + } else { + return false; + } + } + + public function opendir(string $path) { + return opendir($this->constructUrl($path)); + } + + public function filetype(string $path): string|false { + return @filetype($this->constructUrl($path)); + } + + public function file_exists(string $path): bool { + return file_exists($this->constructUrl($path)); + } + + public function unlink(string $path): bool { + $url = $this->constructUrl($path); + $success = unlink($url); + // normally unlink() is supposed to do this implicitly, + // but doing it anyway just to be sure + clearstatcache(false, $url); + return $success; + } + + public function fopen(string $path, string $mode) { + return fopen($this->constructUrl($path), $mode); + } + + public function touch(string $path, ?int $mtime = null): bool { + if ($this->file_exists($path)) { + if (is_null($mtime)) { + $fh = $this->fopen($path, 'a'); + fwrite($fh, ''); + fclose($fh); + + return true; + } else { + return false; //not supported + } + } else { + $this->file_put_contents($path, ''); + return true; + } + } + + public function getFile(string $path, string $target): bool { + return copy($this->constructUrl($path), $target); + } + + public function uploadFile(string $path, string $target): bool { + return copy($path, $this->constructUrl($target)); + } + + public function rename(string $source, string $target): bool { + return rename($this->constructUrl($source), $this->constructUrl($target)); + } + + public function stat(string $path): array|false { + return stat($this->constructUrl($path)); + } +} diff --git a/apps/files_external/lib/Lib/Storage/Swift.php b/apps/files_external/lib/Lib/Storage/Swift.php new file mode 100644 index 00000000000..e80570f14ba --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/Swift.php @@ -0,0 +1,593 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_External\Lib\Storage; + +use GuzzleHttp\Psr7\Uri; +use Icewind\Streams\CallbackWrapper; +use Icewind\Streams\IteratorDirectory; +use OC\Files\Filesystem; +use OC\Files\ObjectStore\SwiftFactory; +use OC\Files\Storage\Common; +use OCP\Cache\CappedMemoryCache; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\StorageAuthException; +use OCP\Files\StorageBadConfigException; +use OCP\Files\StorageNotAvailableException; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\ITempManager; +use OCP\Server; +use OpenStack\Common\Error\BadResponseError; +use OpenStack\ObjectStore\v1\Models\Container; +use OpenStack\ObjectStore\v1\Models\StorageObject; +use Psr\Log\LoggerInterface; + +class Swift extends Common { + /** @var SwiftFactory */ + private $connectionFactory; + /** + * @var Container + */ + private $container; + /** + * @var string + */ + private $bucket; + /** + * Connection parameters + * + * @var array + */ + private $params; + + /** @var string */ + private $id; + + /** @var \OC\Files\ObjectStore\Swift */ + private $objectStore; + + /** @var IMimeTypeDetector */ + private $mimeDetector; + + /** + * Key value cache mapping path to data object. Maps path to + * \OpenCloud\OpenStack\ObjectStorage\Resource\DataObject for existing + * paths and path to false for not existing paths. + * + * @var ICache + */ + private $objectCache; + + private function normalizePath(string $path): string { + $path = trim($path, '/'); + + if (!$path) { + $path = '.'; + } + + $path = str_replace('#', '%23', $path); + + return $path; + } + + public const SUBCONTAINER_FILE = '.subcontainers'; + + /** + * Fetches an object from the API. + * If the object is cached already or a + * failed "doesn't exist" response was cached, + * that one will be returned. + * + * @return StorageObject|false object + * or false if the object did not exist + * @throws StorageAuthException + * @throws StorageNotAvailableException + */ + private function fetchObject(string $path): StorageObject|false { + $cached = $this->objectCache->get($path); + if ($cached !== null) { + // might be "false" if object did not exist from last check + return $cached; + } + try { + $object = $this->getContainer()->getObject($path); + $object->retrieve(); + $this->objectCache->set($path, $object); + return $object; + } catch (BadResponseError $e) { + // Expected response is "404 Not Found", so only log if it isn't + if ($e->getResponse()->getStatusCode() !== 404) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + } + $this->objectCache->set($path, false); + return false; + } + } + + /** + * Returns whether the given path exists. + * + * @return bool true if the object exist, false otherwise + * @throws StorageAuthException + * @throws StorageNotAvailableException + */ + private function doesObjectExist(string $path): bool { + return $this->fetchObject($path) !== false; + } + + public function __construct(array $parameters) { + if ((empty($parameters['key']) and empty($parameters['password'])) + or (empty($parameters['user']) && empty($parameters['userid'])) or empty($parameters['bucket']) + or empty($parameters['region']) + ) { + throw new StorageBadConfigException('API Key or password, Login, Bucket and Region have to be configured.'); + } + + $user = $parameters['user']; + $this->id = 'swift::' . $user . md5($parameters['bucket']); + + $bucketUrl = new Uri($parameters['bucket']); + if ($bucketUrl->getHost()) { + $parameters['bucket'] = basename($bucketUrl->getPath()); + $parameters['endpoint_url'] = (string)$bucketUrl->withPath(dirname($bucketUrl->getPath())); + } + + if (empty($parameters['url'])) { + $parameters['url'] = 'https://identity.api.rackspacecloud.com/v2.0/'; + } + + if (empty($parameters['service_name'])) { + $parameters['service_name'] = 'cloudFiles'; + } + + $parameters['autocreate'] = true; + + if (isset($parameters['domain'])) { + $parameters['user'] = [ + 'name' => $parameters['user'], + 'password' => $parameters['password'], + 'domain' => [ + 'name' => $parameters['domain'], + ] + ]; + } + + $this->params = $parameters; + // FIXME: private class... + $this->objectCache = new CappedMemoryCache(); + $this->connectionFactory = new SwiftFactory( + Server::get(ICacheFactory::class)->createDistributed('swift/'), + $this->params, + Server::get(LoggerInterface::class) + ); + $this->objectStore = new \OC\Files\ObjectStore\Swift($this->params, $this->connectionFactory); + $this->bucket = $parameters['bucket']; + $this->mimeDetector = Server::get(IMimeTypeDetector::class); + } + + public function mkdir(string $path): bool { + $path = $this->normalizePath($path); + + if ($this->is_dir($path)) { + return false; + } + + if ($path !== '.') { + $path .= '/'; + } + + try { + $this->getContainer()->createObject([ + 'name' => $path, + 'content' => '', + 'headers' => ['content-type' => 'httpd/unix-directory'] + ]); + // invalidate so that the next access gets the real object + // with all properties + $this->objectCache->remove($path); + } catch (BadResponseError $e) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + return false; + } + + return true; + } + + public function file_exists(string $path): bool { + $path = $this->normalizePath($path); + + if ($path !== '.' && $this->is_dir($path)) { + $path .= '/'; + } + + return $this->doesObjectExist($path); + } + + public function rmdir(string $path): bool { + $path = $this->normalizePath($path); + + if (!$this->is_dir($path) || !$this->isDeletable($path)) { + return false; + } + + $dh = $this->opendir($path); + while (($file = readdir($dh)) !== false) { + if (Filesystem::isIgnoredDir($file)) { + continue; + } + + if ($this->is_dir($path . '/' . $file)) { + $this->rmdir($path . '/' . $file); + } else { + $this->unlink($path . '/' . $file); + } + } + + try { + $this->objectStore->deleteObject($path . '/'); + $this->objectCache->remove($path . '/'); + } catch (BadResponseError $e) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + return false; + } + + return true; + } + + public function opendir(string $path) { + $path = $this->normalizePath($path); + + if ($path === '.') { + $path = ''; + } else { + $path .= '/'; + } + + // $path = str_replace('%23', '#', $path); // the prefix is sent as a query param, so revert the encoding of # + + try { + $files = []; + $objects = $this->getContainer()->listObjects([ + 'prefix' => $path, + 'delimiter' => '/' + ]); + + /** @var StorageObject $object */ + foreach ($objects as $object) { + $file = basename($object->name); + if ($file !== basename($path) && $file !== '.') { + $files[] = $file; + } + } + + return IteratorDirectory::wrap($files); + } catch (\Exception $e) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + return false; + } + } + + public function stat(string $path): array|false { + $path = $this->normalizePath($path); + if ($path === '.') { + $path = ''; + } elseif ($this->is_dir($path)) { + $path .= '/'; + } + + try { + $object = $this->fetchObject($path); + if (!$object) { + return false; + } + } catch (BadResponseError $e) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + return false; + } + + $mtime = null; + if (!empty($object->lastModified)) { + $dateTime = \DateTime::createFromFormat(\DateTime::RFC1123, $object->lastModified); + if ($dateTime !== false) { + $mtime = $dateTime->getTimestamp(); + } + } + + if (is_numeric($object->getMetadata()['timestamp'] ?? null)) { + $mtime = (float)$object->getMetadata()['timestamp']; + } + + return [ + 'size' => (int)$object->contentLength, + 'mtime' => isset($mtime) ? (int)floor($mtime) : null, + 'atime' => time(), + ]; + } + + public function filetype(string $path) { + $path = $this->normalizePath($path); + + if ($path !== '.' && $this->doesObjectExist($path)) { + return 'file'; + } + + if ($path !== '.') { + $path .= '/'; + } + + if ($this->doesObjectExist($path)) { + return 'dir'; + } + } + + public function unlink(string $path): bool { + $path = $this->normalizePath($path); + + if ($this->is_dir($path)) { + return $this->rmdir($path); + } + + try { + $this->objectStore->deleteObject($path); + $this->objectCache->remove($path); + $this->objectCache->remove($path . '/'); + } catch (BadResponseError $e) { + if ($e->getResponse()->getStatusCode() !== 404) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + throw $e; + } + } + + return true; + } + + public function fopen(string $path, string $mode) { + $path = $this->normalizePath($path); + + switch ($mode) { + case 'a': + case 'ab': + case 'a+': + return false; + case 'r': + case 'rb': + try { + return $this->objectStore->readObject($path); + } catch (BadResponseError $e) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + return false; + } + case 'w': + case 'wb': + case 'r+': + case 'w+': + case 'wb+': + case 'x': + case 'x+': + case 'c': + case 'c+': + if (strrpos($path, '.') !== false) { + $ext = substr($path, strrpos($path, '.')); + } else { + $ext = ''; + } + $tmpFile = Server::get(ITempManager::class)->getTemporaryFile($ext); + // Fetch existing file if required + if ($mode[0] !== 'w' && $this->file_exists($path)) { + if ($mode[0] === 'x') { + // File cannot already exist + return false; + } + $source = $this->fopen($path, 'r'); + file_put_contents($tmpFile, $source); + } + $handle = fopen($tmpFile, $mode); + return CallbackWrapper::wrap($handle, null, null, function () use ($path, $tmpFile): void { + $this->writeBack($tmpFile, $path); + }); + } + } + + public function touch(string $path, ?int $mtime = null): bool { + $path = $this->normalizePath($path); + if (is_null($mtime)) { + $mtime = time(); + } + $metadata = ['timestamp' => (string)$mtime]; + if ($this->file_exists($path)) { + if ($this->is_dir($path) && $path !== '.') { + $path .= '/'; + } + + $object = $this->fetchObject($path); + if ($object->mergeMetadata($metadata)) { + // invalidate target object to force repopulation on fetch + $this->objectCache->remove($path); + } + return true; + } else { + $mimeType = $this->mimeDetector->detectPath($path); + $this->getContainer()->createObject([ + 'name' => $path, + 'content' => '', + 'headers' => ['content-type' => 'httpd/unix-directory'] + ]); + // invalidate target object to force repopulation on fetch + $this->objectCache->remove($path); + return true; + } + } + + public function copy(string $source, string $target): bool { + $source = $this->normalizePath($source); + $target = $this->normalizePath($target); + + $fileType = $this->filetype($source); + if ($fileType) { + // make way + $this->unlink($target); + } + + if ($fileType === 'file') { + try { + $sourceObject = $this->fetchObject($source); + $sourceObject->copy([ + 'destination' => $this->bucket . '/' . $target + ]); + // invalidate target object to force repopulation on fetch + $this->objectCache->remove($target); + $this->objectCache->remove($target . '/'); + } catch (BadResponseError $e) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + return false; + } + } elseif ($fileType === 'dir') { + try { + $sourceObject = $this->fetchObject($source . '/'); + $sourceObject->copy([ + 'destination' => $this->bucket . '/' . $target . '/' + ]); + // invalidate target object to force repopulation on fetch + $this->objectCache->remove($target); + $this->objectCache->remove($target . '/'); + } catch (BadResponseError $e) { + Server::get(LoggerInterface::class)->error($e->getMessage(), [ + 'exception' => $e, + 'app' => 'files_external', + ]); + return false; + } + + $dh = $this->opendir($source); + while (($file = readdir($dh)) !== false) { + if (Filesystem::isIgnoredDir($file)) { + continue; + } + + $source = $source . '/' . $file; + $target = $target . '/' . $file; + $this->copy($source, $target); + } + } else { + //file does not exist + return false; + } + + return true; + } + + public function rename(string $source, string $target): bool { + $source = $this->normalizePath($source); + $target = $this->normalizePath($target); + + $fileType = $this->filetype($source); + + if ($fileType === 'dir' || $fileType === 'file') { + // copy + if ($this->copy($source, $target) === false) { + return false; + } + + // cleanup + if ($this->unlink($source) === false) { + throw new \Exception('failed to remove original'); + $this->unlink($target); + return false; + } + + return true; + } + + return false; + } + + public function getId(): string { + return $this->id; + } + + /** + * Returns the initialized object store container. + * + * @return Container + * @throws StorageAuthException + * @throws StorageNotAvailableException + */ + public function getContainer(): Container { + if (is_null($this->container)) { + $this->container = $this->connectionFactory->getContainer(); + + if (!$this->file_exists('.')) { + $this->mkdir('.'); + } + } + return $this->container; + } + + public function writeBack(string $tmpFile, string $path): void { + $fileData = fopen($tmpFile, 'r'); + $this->objectStore->writeObject($path, $fileData, $this->mimeDetector->detectPath($path)); + // invalidate target object to force repopulation on fetch + $this->objectCache->remove($path); + unlink($tmpFile); + } + + public function hasUpdated(string $path, int $time): bool { + if ($this->is_file($path)) { + return parent::hasUpdated($path, $time); + } + $path = $this->normalizePath($path); + $dh = $this->opendir($path); + $content = []; + while (($file = readdir($dh)) !== false) { + $content[] = $file; + } + if ($path === '.') { + $path = ''; + } + $cachedContent = $this->getCache()->getFolderContents($path); + $cachedNames = array_map(function ($content) { + return $content['name']; + }, $cachedContent); + sort($cachedNames); + sort($content); + return $cachedNames !== $content; + } + + /** + * check if curl is installed + */ + public static function checkDependencies(): bool { + return true; + } +} diff --git a/apps/files_external/lib/Lib/Storage/SystemBridge.php b/apps/files_external/lib/Lib/Storage/SystemBridge.php new file mode 100644 index 00000000000..80449b2744b --- /dev/null +++ b/apps/files_external/lib/Lib/Storage/SystemBridge.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Robin Appelman <robin@icewind.nl> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files_External\Lib\Storage; + +use Icewind\SMB\System; +use OCP\IBinaryFinder; + +/** + * Bridge the NC and SMB binary finding logic + */ +class SystemBridge extends System { + public function __construct( + private IBinaryFinder $binaryFinder, + ) { + } + + protected function getBinaryPath(string $binary): ?string { + $path = $this->binaryFinder->findBinaryPath($binary); + return $path !== false ? $path : null; + } +} |