diff options
Diffstat (limited to 'apps/files_external/lib/Lib/Storage/AmazonS3.php')
-rw-r--r-- | apps/files_external/lib/Lib/Storage/AmazonS3.php | 760 |
1 files changed, 760 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; + } +} |