├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── src └── SmbAdapter.php └── tests └── SmbAdapterTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.idea 3 | /vendor 4 | /composer.lock 5 | /test_config.php 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Rob Gridley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flysystem Adapter for Icewind SMB 2 | 3 | ## Usage 4 | ```php 5 | use Icewind\SMB\BasicAuth; 6 | use Icewind\SMB\ServerFactory; 7 | use League\Flysystem\Filesystem; 8 | use RobGridley\Flysystem\Smb\SmbAdapter; 9 | 10 | $factory = new ServerFactory(); 11 | $auth = new BasicAuth('username', 'domain/workgroup', 'password'); 12 | $server = $factory->createServer('host', $auth); 13 | $share = $server->getShare('name'); 14 | 15 | $filesystem = new Filesystem(new SmbAdapter($share)); 16 | ``` 17 | 18 | ## Installation 19 | ``` 20 | $ composer require robgridley/flysystem-smb 21 | ``` 22 | 23 | ## Note 24 | This adapter does not support visibility. Calls to `visibility()` or `setVisibility()` throw exceptions and setting visibility via writes, moves, etc. is ignored. -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "robgridley/flysystem-smb", 3 | "description": "Flysystem adapter for Icewind SMB.", 4 | "keywords": ["smb", "samba", "flysystem"], 5 | "license": "MIT", 6 | "require": { 7 | "php": "^8.1", 8 | "icewind/smb": "^3.0", 9 | "league/flysystem": "^3.0" 10 | }, 11 | "require-dev": { 12 | "league/flysystem-adapter-test-utilities": "^3.15", 13 | "phpunit/phpunit": "^9.6" 14 | }, 15 | "authors": [ 16 | { 17 | "name": "Rob Gridley", 18 | "email": "me@robgridley.com" 19 | } 20 | ], 21 | "autoload": { 22 | "psr-4": { 23 | "RobGridley\\Flysystem\\Smb\\": "src/" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/SmbAdapter.php: -------------------------------------------------------------------------------- 1 | prefixer = new PathPrefixer($root); 48 | } 49 | 50 | /** 51 | * Determine if the specified file exists. 52 | * 53 | * @param string $path 54 | * @return bool 55 | */ 56 | public function fileExists(string $path): bool 57 | { 58 | try { 59 | $fileInfo = $this->share->stat($this->prefixer->prefixPath($path)); 60 | } catch (NotFoundException $exception) { 61 | return false; 62 | } catch (Throwable $exception) { 63 | throw UnableToCheckExistence::forLocation($path, $exception); 64 | } 65 | 66 | return !$fileInfo->isDirectory(); 67 | } 68 | 69 | /** 70 | * Determine if the specified directory exists. 71 | * 72 | * @param string $path 73 | * @return bool 74 | */ 75 | public function directoryExists(string $path): bool 76 | { 77 | try { 78 | $fileInfo = $this->share->stat($this->prefixer->prefixPath($path)); 79 | } catch (NotFoundException $exception) { 80 | return false; 81 | } catch (Throwable $exception) { 82 | throw UnableToCheckExistence::forLocation($path, $exception); 83 | } 84 | 85 | return $fileInfo->isDirectory(); 86 | } 87 | 88 | /** 89 | * Write a string. 90 | * 91 | * @param string $path 92 | * @param string $contents 93 | * @param Config $config 94 | * @return void 95 | */ 96 | public function write(string $path, string $contents, Config $config): void 97 | { 98 | try { 99 | $this->ensureParentDirectoryExists($path, $config); 100 | $stream = $this->share->write($this->prefixer->prefixPath($path)); 101 | } catch (Throwable $exception) { 102 | throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); 103 | } 104 | 105 | if (false === @fwrite($stream, $contents)) { 106 | throw UnableToWriteFile::atLocation($path, 'Unable to write to SMB stream.'); 107 | } 108 | 109 | if (!fclose($stream)) { 110 | throw UnableToWriteFile::atLocation($path, 'Unable to close SMB stream.'); 111 | } 112 | } 113 | 114 | /** 115 | * Write a stream. 116 | * 117 | * @param string $path 118 | * @param $contents 119 | * @param Config $config 120 | * @return void 121 | */ 122 | public function writeStream(string $path, $contents, Config $config): void 123 | { 124 | try { 125 | $this->ensureParentDirectoryExists($path, $config); 126 | $stream = $this->share->write($this->prefixer->prefixPath($path)); 127 | } catch (Throwable $exception) { 128 | throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); 129 | } 130 | 131 | if (false === stream_copy_to_stream($contents, $stream)) { 132 | throw UnableToWriteFile::atLocation($path, 'Unable to write to SMB stream.'); 133 | } 134 | 135 | if (!fclose($stream)) { 136 | throw UnableToWriteFile::atLocation($path, 'Unable to close SMB stream.'); 137 | } 138 | } 139 | 140 | /** 141 | * Read a file. 142 | * 143 | * @param string $path 144 | * @return string 145 | */ 146 | public function read(string $path): string 147 | { 148 | $contents = stream_get_contents($this->readStream($path)); 149 | 150 | if ($contents === false) { 151 | throw UnableToReadFile::fromLocation($path, 'Unable to read stream.'); 152 | } 153 | 154 | return $contents; 155 | } 156 | 157 | /** 158 | * Open a stream for a file. 159 | * 160 | * @param string $path 161 | * @return resource 162 | */ 163 | public function readStream(string $path) 164 | { 165 | try { 166 | return $this->share->read($this->prefixer->prefixPath($path)); 167 | } catch (Throwable $exception) { 168 | throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); 169 | } 170 | } 171 | 172 | /** 173 | * Delete a file. 174 | * 175 | * @param string $path 176 | * @return void 177 | */ 178 | public function delete(string $path): void 179 | { 180 | try { 181 | $this->share->del($this->prefixer->prefixPath($path)); 182 | } catch (NotFoundException $exception) { 183 | // Do nothing. 184 | } catch (Throwable $exception) { 185 | throw UnableToDeleteFile::atLocation($path, $exception->getMessage(), $exception); 186 | } 187 | } 188 | 189 | /** 190 | * Recursively delete a directory. 191 | * 192 | * @param string $path 193 | * @return void 194 | */ 195 | public function deleteDirectory(string $path): void 196 | { 197 | $directories = [$path]; 198 | 199 | try { 200 | foreach ($this->listContents($path, true) as $item) { 201 | if ($item->isDir()) { 202 | $directories[] = $item->path(); 203 | continue; 204 | } 205 | $this->delete($item->path()); 206 | } 207 | foreach (array_reverse($directories) as $directory) { 208 | $this->share->rmdir($this->prefixer->prefixPath($directory)); 209 | } 210 | } catch (Throwable $exception) { 211 | throw UnableToDeleteDirectory::atLocation($path, $exception->getMessage(), $exception); 212 | } 213 | } 214 | 215 | /** 216 | * Create a directory. 217 | * 218 | * @param string $path 219 | * @param Config $config 220 | * @return void 221 | */ 222 | public function createDirectory(string $path, Config $config): void 223 | { 224 | if ($this->directoryExists($path)) { 225 | return; 226 | } 227 | 228 | $parentDirectory = dirname($path); 229 | 230 | if ($parentDirectory !== '' && $parentDirectory !== '.') { 231 | $this->createDirectory($parentDirectory, $config); 232 | } 233 | 234 | try { 235 | $this->share->mkdir($this->prefixer->prefixPath($path)); 236 | } catch (Throwable $exception) { 237 | throw UnableToCreateDirectory::atLocation($path, $exception->getMessage(), $exception); 238 | } 239 | } 240 | 241 | /** 242 | * Set file visibility. 243 | * 244 | * @param string $path 245 | * @param string $visibility 246 | * @return void 247 | */ 248 | public function setVisibility(string $path, string $visibility): void 249 | { 250 | throw UnableToSetVisibility::atLocation($path, 'SMB does not support this operation.'); 251 | } 252 | 253 | /** 254 | * Get file visibility. 255 | * 256 | * @param string $path 257 | * @return FileAttributes 258 | */ 259 | public function visibility(string $path): FileAttributes 260 | { 261 | throw UnableToRetrieveMetadata::visibility($path, 'SMB does not support this operation.'); 262 | } 263 | 264 | /** 265 | * Determine the MIME-type of a file. 266 | * 267 | * @param string $path 268 | * @return FileAttributes 269 | */ 270 | public function mimeType(string $path): FileAttributes 271 | { 272 | try { 273 | $contents = stream_get_contents($this->readStream($path), 65535); 274 | } catch (Throwable $exception) { 275 | throw UnableToRetrieveMetadata::mimeType($path, 'Unable to read file.'); 276 | } 277 | 278 | $mimeType = $this->mimeTypeDetector->detectMimeType($path, $contents); 279 | 280 | if (is_null($mimeType)) { 281 | throw UnableToRetrieveMetadata::mimeType($path, 'Unable to detect MIME type.'); 282 | } 283 | 284 | return new FileAttributes($path, null, null, null, $mimeType); 285 | } 286 | 287 | /** 288 | * Get the modification date of a file. 289 | * 290 | * @param string $path 291 | * @return FileAttributes 292 | */ 293 | public function lastModified(string $path): FileAttributes 294 | { 295 | try { 296 | $fileInfo = $this->share->stat($this->prefixer->prefixPath($path)); 297 | } catch (Throwable $exception) { 298 | throw UnableToRetrieveMetadata::lastModified($path, $exception->getMessage(), $exception); 299 | } 300 | 301 | return new FileAttributes($path, null, null, $fileInfo->getMTime()); 302 | } 303 | 304 | /** 305 | * Get the size of a file. 306 | * 307 | * @param string $path 308 | * @return FileAttributes 309 | */ 310 | public function fileSize(string $path): FileAttributes 311 | { 312 | try { 313 | $fileInfo = $this->share->stat($this->prefixer->prefixPath($path)); 314 | } catch (Throwable $exception) { 315 | throw UnableToRetrieveMetadata::fileSize($path, $exception->getMessage(), $exception); 316 | } 317 | 318 | if ($fileInfo->isDirectory()) { 319 | throw UnableToRetrieveMetadata::fileSize($path, 'Path is a directory.'); 320 | } 321 | 322 | return new FileAttributes($path, $fileInfo->getSize()); 323 | } 324 | 325 | /** 326 | * List the contents of a directory. 327 | * 328 | * @param string $path 329 | * @param bool $deep 330 | * @return iterable 331 | */ 332 | public function listContents(string $path, bool $deep): iterable 333 | { 334 | try { 335 | $listing = $this->share->dir($this->prefixer->prefixPath($path)); 336 | } catch (Throwable $exception) { 337 | throw UnableToListContents::atLocation($path, $deep, $exception); 338 | } 339 | 340 | foreach ($listing as $fileInfo) { 341 | $attributes = $this->fileInfoToAttributes($fileInfo); 342 | yield $attributes; 343 | 344 | if ($deep && $attributes->isDir()) { 345 | foreach ($this->listContents($attributes->path(), true) as $child) { 346 | yield $child; 347 | } 348 | } 349 | } 350 | } 351 | 352 | /** 353 | * Move a file. 354 | * 355 | * @param string $source 356 | * @param string $destination 357 | * @param Config $config 358 | * @return void 359 | */ 360 | public function move(string $source, string $destination, Config $config): void 361 | { 362 | $sourceLocation = $this->prefixer->prefixPath($source); 363 | $destinationLocation = $this->prefixer->prefixPath($destination); 364 | 365 | try { 366 | $this->ensureParentDirectoryExists($destinationLocation, $config); 367 | $this->share->rename($sourceLocation, $destinationLocation); 368 | } catch (Throwable $exception) { 369 | throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); 370 | } 371 | } 372 | 373 | /** 374 | * Copy a file. 375 | * 376 | * @param string $source 377 | * @param string $destination 378 | * @param Config $config 379 | * @return void 380 | */ 381 | public function copy(string $source, string $destination, Config $config): void 382 | { 383 | try { 384 | $sourceStream = $this->readStream($source); 385 | $this->writeStream($destination, $sourceStream, $config); 386 | } catch (Throwable $exception) { 387 | throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); 388 | } 389 | 390 | fclose($sourceStream); 391 | } 392 | 393 | /** 394 | * Create the parent directories if they do not exist. 395 | * 396 | * @param string $path 397 | * @param Config $config 398 | * @return void 399 | * @throws UnableToCreateDirectory 400 | */ 401 | private function ensureParentDirectoryExists(string $path, Config $config): void 402 | { 403 | $parentDirectory = dirname($path); 404 | 405 | if ($parentDirectory === '' || $parentDirectory === '.') { 406 | return; 407 | } 408 | 409 | $this->createDirectory($parentDirectory, $config); 410 | } 411 | 412 | /** 413 | * Convert an SMB file info instance to a file or directory attributes instance. 414 | * 415 | * @param IFileInfo $fileInfo 416 | * @return StorageAttributes 417 | */ 418 | private function fileInfoToAttributes(IFileInfo $fileInfo): StorageAttributes 419 | { 420 | if ($fileInfo->isDirectory()) { 421 | return new DirectoryAttributes( 422 | $this->prefixer->stripPrefix($fileInfo->getPath()), 423 | null, 424 | $fileInfo->getMTime(), 425 | ); 426 | } 427 | 428 | return new FileAttributes( 429 | $this->prefixer->stripPrefix($fileInfo->getPath()), 430 | $fileInfo->getSize(), 431 | null, 432 | $fileInfo->getMTime(), 433 | ); 434 | } 435 | } 436 | -------------------------------------------------------------------------------- /tests/SmbAdapterTest.php: -------------------------------------------------------------------------------- 1 | createServer($config['host'], $auth); 21 | $share = $server->getShare($config['share']); 22 | 23 | return new SmbAdapter($share, $config['root']); 24 | } 25 | 26 | /** 27 | * @test 28 | */ 29 | public function setting_visibility(): void 30 | { 31 | $this->runScenario(function () { 32 | $adapter = $this->adapter(); 33 | $this->givenWeHaveAnExistingFile('path.txt', 'contents', [Config::OPTION_VISIBILITY => Visibility::PUBLIC]); 34 | 35 | $this->expectException(UnableToSetVisibility::class); 36 | 37 | $adapter->setVisibility('path.txt', Visibility::PRIVATE); 38 | }); 39 | } 40 | 41 | /** 42 | * @test 43 | */ 44 | public function overwriting_a_file(): void 45 | { 46 | $this->runScenario(function () { 47 | $this->givenWeHaveAnExistingFile('path.txt', 'contents', ['visibility' => Visibility::PUBLIC]); 48 | $adapter = $this->adapter(); 49 | 50 | $adapter->write('path.txt', 'new contents', new Config(['visibility' => Visibility::PRIVATE])); 51 | 52 | $contents = $adapter->read('path.txt'); 53 | $this->assertEquals('new contents', $contents); 54 | }); 55 | } 56 | 57 | /** 58 | * @test 59 | */ 60 | public function copying_a_file(): void 61 | { 62 | $this->runScenario(function () { 63 | $adapter = $this->adapter(); 64 | $adapter->write( 65 | 'source.txt', 66 | 'contents to be copied', 67 | new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) 68 | ); 69 | 70 | $adapter->copy('source.txt', 'destination.txt', new Config()); 71 | 72 | $this->assertTrue($adapter->fileExists('source.txt')); 73 | $this->assertTrue($adapter->fileExists('destination.txt')); 74 | $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); 75 | }); 76 | } 77 | 78 | /** 79 | * @test 80 | */ 81 | public function copying_a_file_again(): void 82 | { 83 | $this->runScenario(function () { 84 | $adapter = $this->adapter(); 85 | $adapter->write( 86 | 'source.txt', 87 | 'contents to be copied', 88 | new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) 89 | ); 90 | 91 | $adapter->copy('source.txt', 'destination.txt', new Config()); 92 | 93 | $this->assertTrue($adapter->fileExists('source.txt')); 94 | $this->assertTrue($adapter->fileExists('destination.txt')); 95 | $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); 96 | }); 97 | } 98 | 99 | /** 100 | * @test 101 | */ 102 | public function moving_a_file(): void 103 | { 104 | $this->runScenario(function () { 105 | $adapter = $this->adapter(); 106 | $adapter->write( 107 | 'source.txt', 108 | 'contents to be copied', 109 | new Config([Config::OPTION_VISIBILITY => Visibility::PUBLIC]) 110 | ); 111 | $adapter->move('source.txt', 'destination.txt', new Config()); 112 | $this->assertFalse( 113 | $adapter->fileExists('source.txt'), 114 | 'After moving a file should no longer exist in the original location.' 115 | ); 116 | $this->assertTrue( 117 | $adapter->fileExists('destination.txt'), 118 | 'After moving, a file should be present at the new location.' 119 | ); 120 | $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); 121 | }); 122 | } 123 | } 124 | --------------------------------------------------------------------------------