├── GridFSAdapter.php ├── LICENSE └── composer.json /GridFSAdapter.php: -------------------------------------------------------------------------------- 1 | ['root' => 'array', 'document' => 'array', 'array' => 'array'], 40 | 'codec' => null, 41 | ]; 42 | 43 | private Bucket $bucket; 44 | 45 | private PathPrefixer $prefixer; 46 | 47 | private MimeTypeDetector $mimeTypeDetector; 48 | 49 | public function __construct( 50 | Bucket $bucket, 51 | string $prefix = '', 52 | ?MimeTypeDetector $mimeTypeDetector = null, 53 | ) { 54 | $this->bucket = $bucket; 55 | $this->prefixer = new PathPrefixer($prefix); 56 | $this->mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); 57 | } 58 | 59 | public function fileExists(string $path): bool 60 | { 61 | $file = $this->findFile($path); 62 | 63 | return $file !== null; 64 | } 65 | 66 | public function directoryExists(string $path): bool 67 | { 68 | // A directory exists if at least one file exists with a path starting with the directory name 69 | $files = $this->listContents($path, true); 70 | 71 | foreach ($files as $file) { 72 | return true; 73 | } 74 | 75 | return false; 76 | } 77 | 78 | public function write(string $path, string $contents, Config $config): void 79 | { 80 | if (str_ends_with($path, '/')) { 81 | throw UnableToWriteFile::atLocation($path, 'file path cannot end with a slash'); 82 | } 83 | 84 | $filename = $this->prefixer->prefixPath($path); 85 | $options = [ 86 | 'metadata' => $config->get('metadata', []), 87 | ]; 88 | if ($visibility = $config->get(Config::OPTION_VISIBILITY)) { 89 | $options['metadata'][self::METADATA_VISIBILITY] = $visibility; 90 | } 91 | if (($mimeType = $config->get('mimetype')) || ($mimeType = $this->mimeTypeDetector->detectMimeType($path, $contents))) { 92 | $options['metadata'][self::METADATA_MIMETYPE] = $mimeType; 93 | } 94 | 95 | try { 96 | $stream = $this->bucket->openUploadStream($filename, $options); 97 | fwrite($stream, $contents); 98 | fclose($stream); 99 | } catch (Exception $exception) { 100 | throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); 101 | } 102 | } 103 | 104 | public function writeStream(string $path, $contents, Config $config): void 105 | { 106 | if (str_ends_with($path, '/')) { 107 | throw UnableToWriteFile::atLocation($path, 'file path cannot end with a slash'); 108 | } 109 | 110 | $filename = $this->prefixer->prefixPath($path); 111 | $options = []; 112 | if ($visibility = $config->get(Config::OPTION_VISIBILITY)) { 113 | $options['metadata'][self::METADATA_VISIBILITY] = $visibility; 114 | } 115 | if (($mimetype = $config->get('mimetype')) || ($mimetype = $this->mimeTypeDetector->detectMimeTypeFromPath($path))) { 116 | $options['metadata'][self::METADATA_MIMETYPE] = $mimetype; 117 | } 118 | 119 | try { 120 | $this->bucket->uploadFromStream($filename, $contents, $options); 121 | } catch (Exception $exception) { 122 | throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); 123 | } 124 | } 125 | 126 | public function read(string $path): string 127 | { 128 | $stream = $this->readStream($path); 129 | try { 130 | return stream_get_contents($stream); 131 | } finally { 132 | fclose($stream); 133 | } 134 | } 135 | 136 | public function readStream(string $path) 137 | { 138 | if (str_ends_with($path, '/')) { 139 | throw UnableToReadFile::fromLocation($path, 'file path cannot end with a slash'); 140 | } 141 | 142 | try { 143 | $filename = $this->prefixer->prefixPath($path); 144 | 145 | return $this->bucket->openDownloadStreamByName($filename); 146 | } catch (FileNotFoundException $exception) { 147 | throw UnableToReadFile::fromLocation($path, 'file does not exist', $exception); 148 | } catch (Exception $exception) { 149 | throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); 150 | } 151 | } 152 | 153 | /** 154 | * Delete all revisions of the file name, starting with the oldest, 155 | * no-op if the file does not exist. 156 | * 157 | * @throws UnableToDeleteFile 158 | */ 159 | public function delete(string $path): void 160 | { 161 | if (str_ends_with($path, '/')) { 162 | throw UnableToDeleteFile::atLocation($path, 'file path cannot end with a slash'); 163 | } 164 | 165 | $filename = $this->prefixer->prefixPath($path); 166 | try { 167 | $this->findAndDelete(['filename' => $filename]); 168 | } catch (Exception $exception) { 169 | throw UnableToDeleteFile::atLocation($path, $exception->getMessage(), $exception); 170 | } 171 | } 172 | 173 | public function deleteDirectory(string $path): void 174 | { 175 | $prefixedPath = $this->prefixer->prefixDirectoryPath($path); 176 | try { 177 | $this->findAndDelete(['filename' => new Regex('^' . preg_quote($prefixedPath))]); 178 | } catch (Exception $exception) { 179 | throw UnableToDeleteDirectory::atLocation($path, $exception->getMessage(), $exception); 180 | } 181 | } 182 | 183 | public function createDirectory(string $path, Config $config): void 184 | { 185 | $dirname = $this->prefixer->prefixDirectoryPath($path); 186 | 187 | $options = [ 188 | 'metadata' => $config->get('metadata', []) + [self::METADATA_DIRECTORY => true], 189 | ]; 190 | 191 | if ($visibility = $config->get(Config::OPTION_VISIBILITY)) { 192 | $options['metadata'][self::METADATA_VISIBILITY] = $visibility; 193 | } 194 | 195 | try { 196 | $stream = $this->bucket->openUploadStream($dirname, $options); 197 | fwrite($stream, ''); 198 | fclose($stream); 199 | } catch (Exception $exception) { 200 | throw UnableToCreateDirectory::atLocation($path, $exception->getMessage(), $exception); 201 | } 202 | } 203 | 204 | public function setVisibility(string $path, string $visibility): void 205 | { 206 | $file = $this->findFile($path); 207 | 208 | if ($file === null) { 209 | throw UnableToSetVisibility::atLocation($path, 'file does not exist'); 210 | } 211 | 212 | try { 213 | $this->bucket->getFilesCollection()->updateOne( 214 | ['_id' => $file['_id']], 215 | ['$set' => ['metadata.' . self::METADATA_VISIBILITY => $visibility]], 216 | ); 217 | } catch (Exception $exception) { 218 | throw UnableToSetVisibility::atLocation($path, $exception->getMessage(), $exception); 219 | } 220 | } 221 | 222 | public function visibility(string $path): FileAttributes 223 | { 224 | $file = $this->findFile($path); 225 | 226 | if ($file === null) { 227 | throw UnableToRetrieveMetadata::mimeType($path, 'file does not exist'); 228 | } 229 | 230 | return $this->mapFileAttributes($file); 231 | } 232 | 233 | public function fileSize(string $path): FileAttributes 234 | { 235 | if (str_ends_with($path, '/')) { 236 | throw UnableToRetrieveMetadata::fileSize($path, 'file path cannot end with a slash'); 237 | } 238 | 239 | $file = $this->findFile($path); 240 | if ($file === null) { 241 | throw UnableToRetrieveMetadata::fileSize($path, 'file does not exist'); 242 | } 243 | 244 | return $this->mapFileAttributes($file); 245 | } 246 | 247 | public function mimeType(string $path): FileAttributes 248 | { 249 | if (str_ends_with($path, '/')) { 250 | throw UnableToRetrieveMetadata::mimeType($path, 'file path cannot end with a slash'); 251 | } 252 | 253 | $file = $this->findFile($path); 254 | if ($file === null) { 255 | throw UnableToRetrieveMetadata::mimeType($path, 'file does not exist'); 256 | } 257 | 258 | $attributes = $this->mapFileAttributes($file); 259 | if ($attributes->mimeType() === null) { 260 | throw UnableToRetrieveMetadata::mimeType($path, 'unknown'); 261 | } 262 | 263 | return $attributes; 264 | } 265 | 266 | public function lastModified(string $path): FileAttributes 267 | { 268 | if (str_ends_with($path, '/')) { 269 | throw UnableToRetrieveMetadata::lastModified($path, 'file path cannot end with a slash'); 270 | } 271 | 272 | $file = $this->findFile($path); 273 | if ($file === null) { 274 | throw UnableToRetrieveMetadata::lastModified($path, 'file does not exist'); 275 | } 276 | 277 | return $this->mapFileAttributes($file); 278 | } 279 | 280 | public function listContents(string $path, bool $deep): iterable 281 | { 282 | $path = $this->prefixer->prefixDirectoryPath($path); 283 | 284 | $pathdeep = 0; 285 | // Get the last revision of each file, using the index on the files collection 286 | $pipeline = [['$sort' => ['filename' => 1, 'uploadDate' => 1]]]; 287 | if ($path !== '') { 288 | $pathdeep = substr_count($path, '/'); 289 | // Exclude files that do not start with the expected path 290 | $pipeline[] = ['$match' => ['filename' => new Regex('^' . preg_quote($path))]]; 291 | } 292 | 293 | if ($deep === false) { 294 | $pipeline[] = ['$addFields' => ['splitpath' => ['$split' => ['$filename', '/']]]]; 295 | $pipeline[] = ['$group' => [ 296 | // The same name could be used as a filename and as part of the path of other files 297 | '_id' => [ 298 | 'basename' => ['$arrayElemAt' => ['$splitpath', $pathdeep]], 299 | 'isDir' => ['$ne' => [['$size' => '$splitpath'], $pathdeep + 1]], 300 | ], 301 | // Get the metadata of the last revision of each file 302 | 'file' => ['$last' => '$$ROOT'], 303 | // The "lastModified" date is the date of the last uploaded file in the directory 304 | 'uploadDate' => ['$max' => '$uploadDate'], 305 | ]]; 306 | 307 | $files = $this->bucket->getFilesCollection()->aggregate($pipeline, self::TYPEMAP_ARRAY); 308 | 309 | foreach ($files as $file) { 310 | if ($file['_id']['isDir']) { 311 | yield new DirectoryAttributes( 312 | $this->prefixer->stripDirectoryPrefix($path . $file['_id']['basename']), 313 | null, 314 | $file['uploadDate']->toDateTime()->getTimestamp(), 315 | ); 316 | } else { 317 | yield $this->mapFileAttributes($file['file']); 318 | } 319 | } 320 | } else { 321 | // Get the metadata of the last revision of each file 322 | $pipeline[] = ['$group' => [ 323 | '_id' => '$filename', 324 | 'file' => ['$first' => '$$ROOT'], 325 | ]]; 326 | 327 | $files = $this->bucket->getFilesCollection()->aggregate($pipeline, self::TYPEMAP_ARRAY); 328 | 329 | foreach ($files as $file) { 330 | $file = $file['file']; 331 | if (str_ends_with($file['filename'], '/')) { 332 | // Empty files with a trailing slash are markers for directories, only for Flysystem 333 | yield new DirectoryAttributes( 334 | $this->prefixer->stripDirectoryPrefix($file['filename']), 335 | $file['metadata'][self::METADATA_VISIBILITY] ?? null, 336 | $file['uploadDate']->toDateTime()->getTimestamp(), 337 | $file, 338 | ); 339 | } else { 340 | yield $this->mapFileAttributes($file); 341 | } 342 | } 343 | } 344 | } 345 | 346 | public function move(string $source, string $destination, Config $config): void 347 | { 348 | if ($source === $destination) { 349 | return; 350 | } 351 | 352 | if ($this->fileExists($destination)) { 353 | $this->delete($destination); 354 | } 355 | 356 | try { 357 | $result = $this->bucket->getFilesCollection()->updateMany( 358 | ['filename' => $this->prefixer->prefixPath($source)], 359 | ['$set' => ['filename' => $this->prefixer->prefixPath($destination)]], 360 | ); 361 | 362 | if ($result->getModifiedCount() === 0) { 363 | throw UnableToMoveFile::because('file does not exist', $source, $destination); 364 | } 365 | } catch (Exception $exception) { 366 | throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); 367 | } 368 | } 369 | 370 | public function copy(string $source, string $destination, Config $config): void 371 | { 372 | $file = $this->findFile($source); 373 | 374 | if ($file === null) { 375 | throw UnableToCopyFile::fromLocationTo( 376 | $source, 377 | $destination, 378 | ); 379 | } 380 | 381 | $options = []; 382 | if (($visibility = $config->get(Config::OPTION_VISIBILITY)) || $visibility = $file['metadata'][self::METADATA_VISIBILITY] ?? null) { 383 | $options['metadata'][self::METADATA_VISIBILITY] = $visibility; 384 | } 385 | if (($mimetype = $config->get('mimetype')) || $mimetype = $file['metadata'][self::METADATA_MIMETYPE] ?? null) { 386 | $options['metadata'][self::METADATA_MIMETYPE] = $mimetype; 387 | } 388 | 389 | try { 390 | $stream = $this->bucket->openDownloadStream($file['_id']); 391 | $this->bucket->uploadFromStream($this->prefixer->prefixPath($destination), $stream, $options); 392 | } catch (Exception $exception) { 393 | throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); 394 | } 395 | } 396 | 397 | /** 398 | * Get the last revision of the file name. 399 | * 400 | * @return GridFile|null 401 | */ 402 | private function findFile(string $path): ?array 403 | { 404 | $filename = $this->prefixer->prefixPath($path); 405 | $files = $this->bucket->find( 406 | ['filename' => $filename], 407 | ['sort' => ['uploadDate' => -1], 'limit' => 1] + self::TYPEMAP_ARRAY, 408 | ); 409 | 410 | return $files->toArray()[0] ?? null; 411 | } 412 | 413 | /** 414 | * @param GridFile $file 415 | */ 416 | private function mapFileAttributes(array $file): FileAttributes 417 | { 418 | return new FileAttributes( 419 | $this->prefixer->stripPrefix($file['filename']), 420 | $file['length'], 421 | $file['metadata'][self::METADATA_VISIBILITY] ?? null, 422 | $file['uploadDate']->toDateTime()->getTimestamp(), 423 | $file['metadata'][self::METADATA_MIMETYPE] ?? null, 424 | $file, 425 | ); 426 | } 427 | 428 | /** 429 | * @throws Exception 430 | */ 431 | private function findAndDelete(array $filter): void 432 | { 433 | $files = $this->bucket->find( 434 | $filter, 435 | ['sort' => ['uploadDate' => 1], 'projection' => ['_id' => 1]] + self::TYPEMAP_ARRAY, 436 | ); 437 | 438 | foreach ($files as $file) { 439 | try { 440 | $this->bucket->delete($file['_id']); 441 | } catch (FileNotFoundException) { 442 | // Ignore error due to race condition 443 | } 444 | } 445 | } 446 | } 447 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Frank de Jonge 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "league/flysystem-gridfs", 3 | "autoload": { 4 | "psr-4": { 5 | "League\\Flysystem\\GridFS\\": "" 6 | } 7 | }, 8 | "require": { 9 | "php": "^8.0.2", 10 | "ext-mongodb": "^1.3|^2", 11 | "league/flysystem": "^3.10.0", 12 | "mongodb/mongodb": "^1.2|^2" 13 | }, 14 | "license": "MIT", 15 | "authors": [ 16 | { 17 | "name": "Frank de Jonge", 18 | "email": "info@frankdejonge.nl" 19 | }, 20 | { 21 | "name": "MongoDB PHP", 22 | "email": "driver-php@mongodb.com" 23 | } 24 | ] 25 | } 26 | --------------------------------------------------------------------------------