Skip to content

Commit e982b85

Browse files
committed
PHPLIB-1206 Add default context resolver for GridFS StreamWrapper
1 parent e3b6462 commit e982b85

File tree

5 files changed

+236
-27
lines changed

5 files changed

+236
-27
lines changed

src/GridFS/Bucket.php

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,15 @@
5050
use function MongoDB\BSON\toJSON;
5151
use function property_exists;
5252
use function sprintf;
53+
use function str_contains;
54+
use function str_starts_with;
5355
use function stream_context_create;
5456
use function stream_copy_to_stream;
5557
use function stream_get_meta_data;
5658
use function stream_get_wrappers;
59+
use function strlen;
60+
use function substr;
61+
use function urldecode;
5762
use function urlencode;
5863

5964
/**
@@ -74,6 +79,8 @@ class Bucket
7479

7580
private const STREAM_WRAPPER_PROTOCOL = 'gridfs';
7681

82+
private string $protocol;
83+
7784
private CollectionWrapper $collectionWrapper;
7885

7986
private string $databaseName;
@@ -124,11 +131,16 @@ class Bucket
124131
public function __construct(Manager $manager, string $databaseName, array $options = [])
125132
{
126133
$options += [
134+
'protocol' => self::STREAM_WRAPPER_PROTOCOL,
127135
'bucketName' => self::DEFAULT_BUCKET_NAME,
128136
'chunkSizeBytes' => self::DEFAULT_CHUNK_SIZE_BYTES,
129137
'disableMD5' => false,
130138
];
131139

140+
if (! is_string($options['protocol'])) {
141+
throw InvalidArgumentException::invalidType('"protocol" option', $options['protocol'], 'string');
142+
}
143+
132144
if (! is_string($options['bucketName'])) {
133145
throw InvalidArgumentException::invalidType('"bucketName" option', $options['bucketName'], 'string');
134146
}
@@ -163,6 +175,7 @@ public function __construct(Manager $manager, string $databaseName, array $optio
163175

164176
$this->manager = $manager;
165177
$this->databaseName = $databaseName;
178+
$this->protocol = $options['protocol'];
166179
$this->bucketName = $options['bucketName'];
167180
$this->chunkSizeBytes = $options['chunkSizeBytes'];
168181
$this->disableMD5 = $options['disableMD5'];
@@ -549,7 +562,7 @@ public function openUploadStream(string $filename, array $options = [])
549562

550563
$path = $this->createPathForUpload();
551564
$context = stream_context_create([
552-
self::STREAM_WRAPPER_PROTOCOL => [
565+
$this->protocol => [
553566
'collectionWrapper' => $this->collectionWrapper,
554567
'filename' => $filename,
555568
'options' => $options,
@@ -631,6 +644,60 @@ public function uploadFromStream(string $filename, $source, array $options = [])
631644
return $this->getFileIdForStream($destination);
632645
}
633646

647+
public function createPathForFilename(string $filename): string
648+
{
649+
return $this->createPathForFile((object) ['_id' => $filename]);
650+
}
651+
652+
/**
653+
* Create a stream context from
654+
*
655+
* @see StreamWrapper::setDefaultContextResolver()
656+
* @see stream_context_create()
657+
*
658+
* @param string $path The full url provided to fopen(). It contains the filename.
659+
* gridfs://database_name/collection_name.files/file_name
660+
*
661+
* @return array{collectionWrapper: CollectionWrapper, file: object}|array{collectionWrapper: CollectionWrapper, filename: string, options: array}|null
662+
*/
663+
public function resolveStreamContext(string $path, string $mode): ?array
664+
{
665+
// The file can be read only if it belongs to this bucket
666+
$basePath = $this->createPathForFile((object) ['_id' => '']);
667+
if (! str_starts_with($path, $basePath)) {
668+
return null;
669+
}
670+
671+
$filename = urldecode(substr($path, strlen($basePath)));
672+
673+
if (str_contains($mode, 'r')) {
674+
$file = $this->collectionWrapper->findFileByFilenameAndRevision($filename, -1);
675+
676+
// File not found
677+
if ($file === null) {
678+
return null;
679+
}
680+
681+
return [
682+
'collectionWrapper' => $this->collectionWrapper,
683+
'file' => $file,
684+
];
685+
}
686+
687+
if (str_contains($mode, 'w')) {
688+
return [
689+
'collectionWrapper' => $this->collectionWrapper,
690+
'filename' => $filename,
691+
'options' => [
692+
'chunkSizeBytes' => $this->chunkSizeBytes,
693+
'disableMD5' => $this->disableMD5,
694+
],
695+
];
696+
}
697+
698+
return null;
699+
}
700+
634701
/**
635702
* Creates a path for an existing GridFS file.
636703
*
@@ -646,7 +713,7 @@ private function createPathForFile(object $file): string
646713

647714
return sprintf(
648715
'%s://%s/%s.files/%s',
649-
self::STREAM_WRAPPER_PROTOCOL,
716+
$this->protocol,
650717
urlencode($this->databaseName),
651718
urlencode($this->bucketName),
652719
urlencode($id),
@@ -708,7 +775,7 @@ private function openDownloadStreamByFile(object $file)
708775
{
709776
$path = $this->createPathForFile($file);
710777
$context = stream_context_create([
711-
self::STREAM_WRAPPER_PROTOCOL => [
778+
$this->protocol => [
712779
'collectionWrapper' => $this->collectionWrapper,
713780
'file' => $file,
714781
],

src/GridFS/StreamWrapper.php

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,40 +17,68 @@
1717

1818
namespace MongoDB\GridFS;
1919

20+
use Closure;
2021
use MongoDB\BSON\UTCDateTime;
2122

2223
use function assert;
24+
use function call_user_func;
2325
use function explode;
2426
use function in_array;
27+
use function is_array;
2528
use function is_integer;
29+
use function is_object;
2630
use function is_resource;
31+
use function is_string;
32+
use function sprintf;
33+
use function str_contains;
2734
use function stream_context_get_options;
2835
use function stream_get_wrappers;
2936
use function stream_wrapper_register;
3037
use function stream_wrapper_unregister;
38+
use function trigger_error;
3139

40+
use const E_USER_WARNING;
3241
use const SEEK_CUR;
3342
use const SEEK_END;
3443
use const SEEK_SET;
3544
use const STREAM_IS_URL;
45+
use const STREAM_REPORT_ERRORS;
3646

3747
/**
3848
* Stream wrapper for reading and writing a GridFS file.
3949
*
4050
* @internal
4151
* @see Bucket::openUploadStream()
4252
* @see Bucket::openDownloadStream()
53+
* @psalm-type ContextOptions = array{collectionWrapper: CollectionWrapper, file: object}|array{collectionWrapper: CollectionWrapper, filename: string, options: array}|null
4354
*/
4455
class StreamWrapper
4556
{
4657
/** @var resource|null Stream context (set by PHP) */
4758
public $context;
4859

49-
private ?string $protocol = null;
50-
5160
/** @var ReadableStream|WritableStream|null */
5261
private $stream;
5362

63+
/** @var Closure(string, string): ContextOptions|null */
64+
private static ?Closure $contextResolver = null;
65+
66+
/**
67+
* In order to use the stream wrapper with file names only,...
68+
*
69+
* @see Bucket::resolveStreamContext()
70+
*
71+
* @param Bucket|Closure(string, string):ContextOptions|null $resolver
72+
*/
73+
public static function setDefaultContextResolver($resolver): void
74+
{
75+
if ($resolver instanceof Bucket) {
76+
$resolver = Closure::fromCallable([$resolver, 'resolveStreamContext']);
77+
}
78+
79+
self::$contextResolver = $resolver;
80+
}
81+
5482
public function __destruct()
5583
{
5684
/* This destructor is a workaround for PHP trying to use the stream well
@@ -122,14 +150,44 @@ public function stream_eof(): bool
122150
*/
123151
public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
124152
{
125-
$this->initProtocol($path);
153+
$protocol = $this->parseProtocol($path);
154+
155+
assert(is_resource($this->context));
156+
$contextOptions = stream_context_get_options($this->context)[$protocol] ?? null;
126157

127-
if ($mode === 'r') {
128-
return $this->initReadableStream();
158+
if ($contextOptions === null) {
159+
if (! isset(self::$contextResolver)) {
160+
if ($options & STREAM_REPORT_ERRORS) {
161+
trigger_error(sprintf('No stream context provided for "%s" protocol. Use "%s::setDefaultContextResolver() to provide a default context."', $protocol, self::class), E_USER_WARNING);
162+
}
163+
164+
return false;
165+
}
166+
167+
$contextOptions = call_user_func(self::$contextResolver, $path, $mode);
168+
if ($contextOptions === null) {
169+
if ($options & STREAM_REPORT_ERRORS) {
170+
trigger_error(sprintf('File not found "%s" with the default GridFS resolver.', $path), E_USER_WARNING);
171+
}
172+
173+
return false;
174+
}
129175
}
130176

131-
if ($mode === 'w') {
132-
return $this->initWritableStream();
177+
assert(is_array($contextOptions));
178+
assert(isset($contextOptions['collectionWrapper']) && $contextOptions['collectionWrapper'] instanceof CollectionWrapper);
179+
180+
if (str_contains($mode, 'r')) {
181+
assert(isset($contextOptions['file']) && is_object($contextOptions['file']));
182+
183+
return $this->initReadableStream($contextOptions);
184+
}
185+
186+
if (str_contains($mode, 'w')) {
187+
assert(isset($contextOptions['filename']) && is_string($contextOptions['filename']));
188+
assert(isset($contextOptions['options']) && is_array($contextOptions['options']));
189+
190+
return $this->initWritableStream($contextOptions);
133191
}
134192

135193
return false;
@@ -278,26 +336,24 @@ private function getStatTemplate(): array
278336
*
279337
* @see StreamWrapper::stream_open()
280338
*/
281-
private function initProtocol(string $path): void
339+
private function parseProtocol(string $path): string
282340
{
283341
$parts = explode('://', $path, 2);
284-
$this->protocol = $parts[0] ?: 'gridfs';
342+
343+
return $parts[0] ?: 'gridfs';
285344
}
286345

287346
/**
288347
* Initialize the internal stream for reading.
289348
*
349+
* @param array{collectionWrapper: CollectionWrapper, file: object, ...} $contextOptions
290350
* @see StreamWrapper::stream_open()
291351
*/
292-
private function initReadableStream(): bool
352+
private function initReadableStream(array $contextOptions): bool
293353
{
294-
assert(is_resource($this->context));
295-
$context = stream_context_get_options($this->context);
296-
297-
assert($this->protocol !== null);
298354
$this->stream = new ReadableStream(
299-
$context[$this->protocol]['collectionWrapper'],
300-
$context[$this->protocol]['file'],
355+
$contextOptions['collectionWrapper'],
356+
$contextOptions['file'],
301357
);
302358

303359
return true;
@@ -306,18 +362,15 @@ private function initReadableStream(): bool
306362
/**
307363
* Initialize the internal stream for writing.
308364
*
365+
* @param array{collectionWrapper: CollectionWrapper, filename: string, options: array, ...} $contextOptions
309366
* @see StreamWrapper::stream_open()
310367
*/
311-
private function initWritableStream(): bool
368+
private function initWritableStream(array $contextOptions): bool
312369
{
313-
assert(is_resource($this->context));
314-
$context = stream_context_get_options($this->context);
315-
316-
assert($this->protocol !== null);
317370
$this->stream = new WritableStream(
318-
$context[$this->protocol]['collectionWrapper'],
319-
$context[$this->protocol]['filename'],
320-
$context[$this->protocol]['options'],
371+
$contextOptions['collectionWrapper'],
372+
$contextOptions['filename'],
373+
$contextOptions['options'],
321374
);
322375

323376
return true;

tests/GridFS/BucketFunctionalTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
namespace MongoDB\Tests\GridFS;
44

55
use MongoDB\BSON\Binary;
6+
use MongoDB\BSON\ObjectId;
67
use MongoDB\Collection;
78
use MongoDB\Driver\ReadConcern;
89
use MongoDB\Driver\ReadPreference;
910
use MongoDB\Driver\WriteConcern;
1011
use MongoDB\Exception\InvalidArgumentException;
1112
use MongoDB\GridFS\Bucket;
13+
use MongoDB\GridFS\CollectionWrapper;
1214
use MongoDB\GridFS\Exception\CorruptFileException;
1315
use MongoDB\GridFS\Exception\FileNotFoundException;
1416
use MongoDB\GridFS\Exception\StreamException;
@@ -745,6 +747,44 @@ public function testDanglingOpenWritableStream(): void
745747
$this->assertSame('', $output);
746748
}
747749

750+
public function testCreatePathForFilename(): void
751+
{
752+
$filename = 'filename';
753+
$expected = sprintf('gridfs://%s/%s.files/%s', $this->bucket->getDatabaseName(), $this->bucket->getBucketName(), $filename);
754+
755+
$this->assertSame($expected, $this->bucket->createPathForFilename($filename));
756+
}
757+
758+
public function testResolveStreamContextForRead(): void
759+
{
760+
$stream = $this->bucket->openUploadStream('filename');
761+
fwrite($stream, 'foobar');
762+
fclose($stream);
763+
764+
$context = $this->bucket->resolveStreamContext($this->bucket->createPathForFilename('filename'), 'rb');
765+
766+
$this->assertIsArray($context);
767+
$this->assertArrayHasKey('collectionWrapper', $context);
768+
$this->assertInstanceOf(CollectionWrapper::class, $context['collectionWrapper']);
769+
$this->assertArrayHasKey('file', $context);
770+
$this->assertIsObject($context['file']);
771+
$this->assertInstanceOf(ObjectId::class, $context['file']->_id);
772+
$this->assertSame('filename', $context['file']->filename);
773+
}
774+
775+
public function testResolveStreamContextForWrite(): void
776+
{
777+
$context = $this->bucket->resolveStreamContext($this->bucket->createPathForFilename('filename'), 'wb');
778+
779+
$this->assertIsArray($context);
780+
$this->assertArrayHasKey('collectionWrapper', $context);
781+
$this->assertInstanceOf(CollectionWrapper::class, $context['collectionWrapper']);
782+
$this->assertArrayHasKey('filename', $context);
783+
$this->assertSame('filename', $context['filename']);
784+
$this->assertArrayHasKey('options', $context);
785+
$this->assertSame(['chunkSizeBytes' => 261120, 'disableMD5' => false], $context['options']);
786+
}
787+
748788
/**
749789
* Asserts that an index with the given name exists for the collection.
750790
*

tests/GridFS/FunctionalTestCase.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use MongoDB\Collection;
66
use MongoDB\GridFS\Bucket;
7+
use MongoDB\GridFS\StreamWrapper;
78
use MongoDB\Tests\FunctionalTestCase as BaseFunctionalTestCase;
89

910
use function fopen;
@@ -34,6 +35,13 @@ public function setUp(): void
3435
$this->filesCollection = $this->createCollection($this->getDatabaseName(), 'fs.files');
3536
}
3637

38+
public function tearDown(): void
39+
{
40+
StreamWrapper::setDefaultContextResolver(null);
41+
42+
parent::tearDown();
43+
}
44+
3745
/**
3846
* Asserts that a variable is a stream containing the expected data.
3947
*

0 commit comments

Comments
 (0)