diff --git a/src/GridFS/Bucket.php b/src/GridFS/Bucket.php index f571586ed..b1c6b361a 100644 --- a/src/GridFS/Bucket.php +++ b/src/GridFS/Bucket.php @@ -50,10 +50,15 @@ use function MongoDB\BSON\toJSON; use function property_exists; use function sprintf; +use function str_contains; +use function str_starts_with; use function stream_context_create; use function stream_copy_to_stream; use function stream_get_meta_data; use function stream_get_wrappers; +use function strlen; +use function substr; +use function urldecode; use function urlencode; /** @@ -74,6 +79,8 @@ class Bucket private const STREAM_WRAPPER_PROTOCOL = 'gridfs'; + private string $protocol; + private CollectionWrapper $collectionWrapper; private string $databaseName; @@ -124,11 +131,16 @@ class Bucket public function __construct(Manager $manager, string $databaseName, array $options = []) { $options += [ + 'protocol' => self::STREAM_WRAPPER_PROTOCOL, 'bucketName' => self::DEFAULT_BUCKET_NAME, 'chunkSizeBytes' => self::DEFAULT_CHUNK_SIZE_BYTES, 'disableMD5' => false, ]; + if (! is_string($options['protocol'])) { + throw InvalidArgumentException::invalidType('"protocol" option', $options['protocol'], 'string'); + } + if (! is_string($options['bucketName'])) { throw InvalidArgumentException::invalidType('"bucketName" option', $options['bucketName'], 'string'); } @@ -163,6 +175,7 @@ public function __construct(Manager $manager, string $databaseName, array $optio $this->manager = $manager; $this->databaseName = $databaseName; + $this->protocol = $options['protocol']; $this->bucketName = $options['bucketName']; $this->chunkSizeBytes = $options['chunkSizeBytes']; $this->disableMD5 = $options['disableMD5']; @@ -549,7 +562,7 @@ public function openUploadStream(string $filename, array $options = []) $path = $this->createPathForUpload(); $context = stream_context_create([ - self::STREAM_WRAPPER_PROTOCOL => [ + $this->protocol => [ 'collectionWrapper' => $this->collectionWrapper, 'filename' => $filename, 'options' => $options, @@ -631,6 +644,60 @@ public function uploadFromStream(string $filename, $source, array $options = []) return $this->getFileIdForStream($destination); } + public function createPathForFilename(string $filename): string + { + return $this->createPathForFile((object) ['_id' => $filename]); + } + + /** + * Create a stream context from + * + * @see StreamWrapper::setDefaultContextResolver() + * @see stream_context_create() + * + * @param string $path The full url provided to fopen(). It contains the filename. + * gridfs://database_name/collection_name.files/file_name + * + * @return array{collectionWrapper: CollectionWrapper, file: object}|array{collectionWrapper: CollectionWrapper, filename: string, options: array}|null + */ + public function resolveStreamContext(string $path, string $mode): ?array + { + // The file can be read only if it belongs to this bucket + $basePath = $this->createPathForFile((object) ['_id' => '']); + if (! str_starts_with($path, $basePath)) { + return null; + } + + $filename = urldecode(substr($path, strlen($basePath))); + + if (str_contains($mode, 'r')) { + $file = $this->collectionWrapper->findFileByFilenameAndRevision($filename, -1); + + // File not found + if ($file === null) { + return null; + } + + return [ + 'collectionWrapper' => $this->collectionWrapper, + 'file' => $file, + ]; + } + + if (str_contains($mode, 'w')) { + return [ + 'collectionWrapper' => $this->collectionWrapper, + 'filename' => $filename, + 'options' => [ + 'chunkSizeBytes' => $this->chunkSizeBytes, + 'disableMD5' => $this->disableMD5, + ], + ]; + } + + return null; + } + /** * Creates a path for an existing GridFS file. * @@ -646,7 +713,7 @@ private function createPathForFile(object $file): string return sprintf( '%s://%s/%s.files/%s', - self::STREAM_WRAPPER_PROTOCOL, + $this->protocol, urlencode($this->databaseName), urlencode($this->bucketName), urlencode($id), @@ -708,7 +775,7 @@ private function openDownloadStreamByFile(object $file) { $path = $this->createPathForFile($file); $context = stream_context_create([ - self::STREAM_WRAPPER_PROTOCOL => [ + $this->protocol => [ 'collectionWrapper' => $this->collectionWrapper, 'file' => $file, ], diff --git a/src/GridFS/StreamWrapper.php b/src/GridFS/StreamWrapper.php index 71a36d07b..be8794580 100644 --- a/src/GridFS/StreamWrapper.php +++ b/src/GridFS/StreamWrapper.php @@ -17,22 +17,32 @@ namespace MongoDB\GridFS; +use Closure; use MongoDB\BSON\UTCDateTime; use function assert; +use function call_user_func; use function explode; use function in_array; +use function is_array; use function is_integer; +use function is_object; use function is_resource; +use function is_string; +use function sprintf; +use function str_contains; use function stream_context_get_options; use function stream_get_wrappers; use function stream_wrapper_register; use function stream_wrapper_unregister; +use function trigger_error; +use const E_USER_WARNING; use const SEEK_CUR; use const SEEK_END; use const SEEK_SET; use const STREAM_IS_URL; +use const STREAM_REPORT_ERRORS; /** * Stream wrapper for reading and writing a GridFS file. @@ -40,17 +50,35 @@ * @internal * @see Bucket::openUploadStream() * @see Bucket::openDownloadStream() + * @psalm-type ContextOptions = array{collectionWrapper: CollectionWrapper, file: object}|array{collectionWrapper: CollectionWrapper, filename: string, options: array}|null */ class StreamWrapper { /** @var resource|null Stream context (set by PHP) */ public $context; - private ?string $protocol = null; - /** @var ReadableStream|WritableStream|null */ private $stream; + /** @var Closure(string, string): ContextOptions|null */ + private static ?Closure $contextResolver = null; + + /** + * In order to use the stream wrapper with file names only,... + * + * @see Bucket::resolveStreamContext() + * + * @param Bucket|Closure(string, string):ContextOptions|null $resolver + */ + public static function setDefaultContextResolver($resolver): void + { + if ($resolver instanceof Bucket) { + $resolver = Closure::fromCallable([$resolver, 'resolveStreamContext']); + } + + self::$contextResolver = $resolver; + } + public function __destruct() { /* This destructor is a workaround for PHP trying to use the stream well @@ -122,14 +150,44 @@ public function stream_eof(): bool */ public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool { - $this->initProtocol($path); + $protocol = $this->parseProtocol($path); + + assert(is_resource($this->context)); + $contextOptions = stream_context_get_options($this->context)[$protocol] ?? null; - if ($mode === 'r') { - return $this->initReadableStream(); + if ($contextOptions === null) { + if (! isset(self::$contextResolver)) { + if ($options & STREAM_REPORT_ERRORS) { + trigger_error(sprintf('No stream context provided for "%s" protocol. Use "%s::setDefaultContextResolver() to provide a default context."', $protocol, self::class), E_USER_WARNING); + } + + return false; + } + + $contextOptions = call_user_func(self::$contextResolver, $path, $mode); + if ($contextOptions === null) { + if ($options & STREAM_REPORT_ERRORS) { + trigger_error(sprintf('File not found "%s" with the default GridFS resolver.', $path), E_USER_WARNING); + } + + return false; + } } - if ($mode === 'w') { - return $this->initWritableStream(); + assert(is_array($contextOptions)); + assert(isset($contextOptions['collectionWrapper']) && $contextOptions['collectionWrapper'] instanceof CollectionWrapper); + + if (str_contains($mode, 'r')) { + assert(isset($contextOptions['file']) && is_object($contextOptions['file'])); + + return $this->initReadableStream($contextOptions); + } + + if (str_contains($mode, 'w')) { + assert(isset($contextOptions['filename']) && is_string($contextOptions['filename'])); + assert(isset($contextOptions['options']) && is_array($contextOptions['options'])); + + return $this->initWritableStream($contextOptions); } return false; @@ -278,26 +336,24 @@ private function getStatTemplate(): array * * @see StreamWrapper::stream_open() */ - private function initProtocol(string $path): void + private function parseProtocol(string $path): string { $parts = explode('://', $path, 2); - $this->protocol = $parts[0] ?: 'gridfs'; + + return $parts[0] ?: 'gridfs'; } /** * Initialize the internal stream for reading. * + * @param array{collectionWrapper: CollectionWrapper, file: object, ...} $contextOptions * @see StreamWrapper::stream_open() */ - private function initReadableStream(): bool + private function initReadableStream(array $contextOptions): bool { - assert(is_resource($this->context)); - $context = stream_context_get_options($this->context); - - assert($this->protocol !== null); $this->stream = new ReadableStream( - $context[$this->protocol]['collectionWrapper'], - $context[$this->protocol]['file'], + $contextOptions['collectionWrapper'], + $contextOptions['file'], ); return true; @@ -306,18 +362,15 @@ private function initReadableStream(): bool /** * Initialize the internal stream for writing. * + * @param array{collectionWrapper: CollectionWrapper, filename: string, options: array, ...} $contextOptions * @see StreamWrapper::stream_open() */ - private function initWritableStream(): bool + private function initWritableStream(array $contextOptions): bool { - assert(is_resource($this->context)); - $context = stream_context_get_options($this->context); - - assert($this->protocol !== null); $this->stream = new WritableStream( - $context[$this->protocol]['collectionWrapper'], - $context[$this->protocol]['filename'], - $context[$this->protocol]['options'], + $contextOptions['collectionWrapper'], + $contextOptions['filename'], + $contextOptions['options'], ); return true; diff --git a/tests/GridFS/BucketFunctionalTest.php b/tests/GridFS/BucketFunctionalTest.php index ebeb4d6c2..dac9bbf5c 100644 --- a/tests/GridFS/BucketFunctionalTest.php +++ b/tests/GridFS/BucketFunctionalTest.php @@ -3,12 +3,14 @@ namespace MongoDB\Tests\GridFS; use MongoDB\BSON\Binary; +use MongoDB\BSON\ObjectId; use MongoDB\Collection; use MongoDB\Driver\ReadConcern; use MongoDB\Driver\ReadPreference; use MongoDB\Driver\WriteConcern; use MongoDB\Exception\InvalidArgumentException; use MongoDB\GridFS\Bucket; +use MongoDB\GridFS\CollectionWrapper; use MongoDB\GridFS\Exception\CorruptFileException; use MongoDB\GridFS\Exception\FileNotFoundException; use MongoDB\GridFS\Exception\StreamException; @@ -745,6 +747,44 @@ public function testDanglingOpenWritableStream(): void $this->assertSame('', $output); } + public function testCreatePathForFilename(): void + { + $filename = 'filename'; + $expected = sprintf('gridfs://%s/%s.files/%s', $this->bucket->getDatabaseName(), $this->bucket->getBucketName(), $filename); + + $this->assertSame($expected, $this->bucket->createPathForFilename($filename)); + } + + public function testResolveStreamContextForRead(): void + { + $stream = $this->bucket->openUploadStream('filename'); + fwrite($stream, 'foobar'); + fclose($stream); + + $context = $this->bucket->resolveStreamContext($this->bucket->createPathForFilename('filename'), 'rb'); + + $this->assertIsArray($context); + $this->assertArrayHasKey('collectionWrapper', $context); + $this->assertInstanceOf(CollectionWrapper::class, $context['collectionWrapper']); + $this->assertArrayHasKey('file', $context); + $this->assertIsObject($context['file']); + $this->assertInstanceOf(ObjectId::class, $context['file']->_id); + $this->assertSame('filename', $context['file']->filename); + } + + public function testResolveStreamContextForWrite(): void + { + $context = $this->bucket->resolveStreamContext($this->bucket->createPathForFilename('filename'), 'wb'); + + $this->assertIsArray($context); + $this->assertArrayHasKey('collectionWrapper', $context); + $this->assertInstanceOf(CollectionWrapper::class, $context['collectionWrapper']); + $this->assertArrayHasKey('filename', $context); + $this->assertSame('filename', $context['filename']); + $this->assertArrayHasKey('options', $context); + $this->assertSame(['chunkSizeBytes' => 261120, 'disableMD5' => false], $context['options']); + } + /** * Asserts that an index with the given name exists for the collection. * diff --git a/tests/GridFS/FunctionalTestCase.php b/tests/GridFS/FunctionalTestCase.php index 1508fef68..51f9e0bb8 100644 --- a/tests/GridFS/FunctionalTestCase.php +++ b/tests/GridFS/FunctionalTestCase.php @@ -4,6 +4,7 @@ use MongoDB\Collection; use MongoDB\GridFS\Bucket; +use MongoDB\GridFS\StreamWrapper; use MongoDB\Tests\FunctionalTestCase as BaseFunctionalTestCase; use function fopen; @@ -34,6 +35,13 @@ public function setUp(): void $this->filesCollection = $this->createCollection($this->getDatabaseName(), 'fs.files'); } + public function tearDown(): void + { + StreamWrapper::setDefaultContextResolver(null); + + parent::tearDown(); + } + /** * Asserts that a variable is a stream containing the expected data. * diff --git a/tests/GridFS/StreamWrapperFunctionalTest.php b/tests/GridFS/StreamWrapperFunctionalTest.php index ebe6eb9ba..35f4a20f5 100644 --- a/tests/GridFS/StreamWrapperFunctionalTest.php +++ b/tests/GridFS/StreamWrapperFunctionalTest.php @@ -4,13 +4,16 @@ use MongoDB\BSON\Binary; use MongoDB\BSON\UTCDateTime; +use MongoDB\GridFS\StreamWrapper; use function fclose; use function feof; +use function fopen; use function fread; use function fseek; use function fstat; use function fwrite; +use function sprintf; use const SEEK_CUR; use const SEEK_END; @@ -204,4 +207,42 @@ public function testWritableStreamWrite(): void $this->assertSame(6, fwrite($stream, 'foobar')); } + + public function testStreamWithDefaultResolver(): void + { + StreamWrapper::setDefaultContextResolver($this->bucket); + + $stream = fopen($this->getFileUrl('filename'), 'wb'); + + $this->assertSame(6, fwrite($stream, 'foobar')); + $this->assertTrue(fclose($stream)); + + $stream = fopen($this->getFileUrl('filename'), 'rb'); + + $this->assertSame('foobar', fread($stream, 10)); + $this->assertTrue(fclose($stream)); + } + + public function testFileNoFoundWithDefaultResolver(): void + { + StreamWrapper::setDefaultContextResolver($this->bucket); + + $this->expectWarning(); + $stream = fopen($this->getFileUrl('filename'), 'r'); + + $this->assertFalse($stream); + } + + public function testFileNoFoundWithoutDefaultResolver(): void + { + $this->expectWarning(); + $stream = fopen($this->bucket->getFileUrl('filename'), 'r'); + + $this->assertFalse($stream); + } + + private function getFileUrl(string $filename): string + { + return sprintf('gridfs://%s/%s.files/%s', $this->bucket->getDatabaseName(), $this->bucket->getBucketName(), $filename); + } }