├── .gitattributes ├── .gitignore ├── LICENSE.md ├── composer.json ├── composer.lock ├── phpcs.xml ├── phpstan.neon ├── phpunit.xml ├── readme.md └── src ├── LaravelSmbAdapterProvider.php └── SmbAdapter.php /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github export-ignore 2 | /tests export-ignore -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.phpunit.result.cache 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Jerodev 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 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jerodev/flysystem-v3-smb-adapter", 3 | "description": "SMB adapter for Flysystem v3", 4 | "keywords": [ 5 | "filesystem", 6 | "flysystem", 7 | "laravel", 8 | "samba", 9 | "smb" 10 | ], 11 | "type": "library", 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "jerodev", 16 | "email": "jeroen@deviaene.eu" 17 | } 18 | ], 19 | "repositories": [ 20 | { 21 | "type": "vcs", 22 | "url": "git@github.com:jerodev/code-styles.git" 23 | } 24 | ], 25 | "autoload": { 26 | "psr-4": { 27 | "Jerodev\\Flysystem\\Smb\\": "src/" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "Jerodev\\Flysystem\\Smb\\Tests\\": "tests/" 33 | } 34 | }, 35 | "require": { 36 | "php": ">=8.0.2", 37 | "icewind/smb": "^3.5", 38 | "league/flysystem": "^3.0" 39 | }, 40 | "require-dev": { 41 | "illuminate/filesystem": "^9.0", 42 | "illuminate/support": "^9.0", 43 | "jerodev/code-styles": "dev-master", 44 | "league/flysystem-adapter-test-utilities": "^3.0", 45 | "orchestra/testbench": "^7.7", 46 | "phpstan/phpstan": "^1.4", 47 | "phpunit/phpunit": "^9.5" 48 | }, 49 | "suggest": { 50 | "ext-smbclient": "Required to use this package" 51 | }, 52 | "extra": { 53 | "laravel": { 54 | "providers": [ 55 | "Jerodev\\Flysystem\\Smb\\LaravelSmbAdapterProvider" 56 | ] 57 | } 58 | }, 59 | "config": { 60 | "optimize-autoloader": true, 61 | "preferred-install": "dist", 62 | "sort-packages": true, 63 | "allow-plugins": { 64 | "dealerdirect/phpcodesniffer-composer-installer": true 65 | }, 66 | "platform": { 67 | "php": "8.0.2" 68 | } 69 | }, 70 | "minimum-stability": "dev", 71 | "prefer-stable": true 72 | } 73 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | src 9 | 10 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: max 3 | paths: 4 | - src 5 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | tests 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # SMB adapter for Flysystem v3 2 | [![run-tests](https://github.com/jerodev/flysystem-v3-smb-adapter/actions/workflows/run-tests.yml/badge.svg)](https://github.com/jerodev/flysystem-v3-smb-adapter/actions/workflows/run-tests.yml) [![Latest Stable Version](http://poser.pugx.org/jerodev/flysystem-v3-smb-adapter/v)](https://packagist.org/packages/jerodev/flysystem-v3-smb-adapter) 3 | 4 | This package enables you to communicate with SMB shares through [Flysystem v3](https://github.com/thephpleague/flysystem). 5 | 6 | ## Installation 7 | 8 | composer require jerodev/flysystem-v3-smb-adapter 9 | 10 | ## Usage 11 | 12 | The adapter uses the [Icewind SMB](https://github.com/icewind1991/SMB) package to communicate with the share. 13 | To use the flysystem adapter, you have to pass it an instance of [`\Icewind\SMB\IShare`](https://github.com/icewind1991/SMB/blob/master/src/IShare.php). Below is an example of how to create a share instance using the factory provided by Icewind SMB. 14 | 15 | ```php 16 | $server = (new \Icewind\SMB\ServerFactory())->createServer( 17 | $config->host, 18 | new \Icewind\SMB\BasicAuth( 19 | $config->user, 20 | 'test', 21 | $config->password 22 | ) 23 | ); 24 | $share = $server->getShare($config->share); 25 | 26 | return new \Jerodev\Flysystem\Smb\SmbAdapter($share, ''); 27 | ``` 28 | 29 | ## Laravel Filesystem 30 | The package also ships with a Laravel service provider that automatically registers a driver for you. Laravel will discover this provider for you when installing this package. 31 | All you have to do is configure the share in your `config/filesystems.php` similar to the example below. 32 | 33 | ```php 34 | 'disks' => [ 35 | 'smb_share' => [ 36 | 'driver' => 'smb', 37 | 'workgroup' => 'WORKGROUP', 38 | 'host' => \env('SMB_HOST', '127.0.0.1'), 39 | 'path' => \env('SMB_PATH', 'test'), 40 | 'username' => \env('SMB_USERNAME', ''), 41 | 'password' => \env('SMB_PASSWORD', ''), 42 | 43 | // Optional Icewind SMB options 44 | 'smb_version_min' => \Icewind\SMB\IOptions::PROTOCOL_SMB2, 45 | 'smb_version_max' => \Icewind\SMB\IOptions::PROTOCOL_SMB2_24, 46 | 'timeout' => 20, 47 | ], 48 | ], 49 | ``` 50 | -------------------------------------------------------------------------------- /src/LaravelSmbAdapterProvider.php: -------------------------------------------------------------------------------- 1 | setMinProtocol($config['smb_version_min'] ?? null); 20 | $options->setMaxProtocol($config['smb_version_max'] ?? null); 21 | $options->setTimeout($config['timeout'] ?? 20); 22 | 23 | $server = (new ServerFactory($options))->createServer( 24 | $config['host'], 25 | new BasicAuth($config['username'], $config['workgroup'] ?? 'WORKGROUP', $config['password']) 26 | ); 27 | $share = $server->getShare($config['path']); 28 | $adapter = new SmbAdapter($share); 29 | 30 | return new FilesystemAdapter( 31 | new Filesystem($adapter, $config), 32 | $adapter, 33 | $config, 34 | ); 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/SmbAdapter.php: -------------------------------------------------------------------------------- 1 | */ 29 | private array $fakeVisibility = []; 30 | 31 | public function __construct( 32 | private IShare $share, 33 | string $prefix = '', 34 | ) { 35 | $this->mimeTypeDetector = new FinfoMimeTypeDetector(); 36 | $this->prefixer = new PathPrefixer($prefix, DIRECTORY_SEPARATOR); 37 | } 38 | 39 | public function fileExists(string $path): bool 40 | { 41 | $location = $this->prefixer->prefixPath($path); 42 | 43 | try { 44 | $this->share->stat($location); 45 | } catch (NotFoundException) { 46 | return false; 47 | } 48 | 49 | return true; 50 | } 51 | 52 | public function directoryExists(string $path): bool 53 | { 54 | return $this->fileExists($path); 55 | } 56 | 57 | public function write(string $path, string $contents, Config $config): void 58 | { 59 | $this->recursiveCreateDir(\dirname($path)); 60 | 61 | $location = $this->prefixer->prefixPath($path); 62 | 63 | try { 64 | $stream = $this->share->write($location); 65 | \fwrite($stream, $contents); 66 | 67 | if ($visibility = $config->get(Config::OPTION_VISIBILITY)) { 68 | $this->fakeVisibility[$path] = \strval($visibility); 69 | } 70 | } catch (Throwable $e) { 71 | throw UnableToWriteFile::atLocation($location, '', $e); 72 | } finally { 73 | if (isset($stream)) { 74 | \fclose($stream); 75 | } 76 | } 77 | } 78 | 79 | public function writeStream(string $path, $resource, Config $config): void 80 | { 81 | $this->recursiveCreateDir(\dirname($path)); 82 | 83 | $location = $this->prefixer->prefixPath($path); 84 | 85 | try { 86 | $stream = $this->share->write($location); 87 | \stream_copy_to_stream($resource, $stream); 88 | 89 | if ($visibility = $config->get(Config::OPTION_VISIBILITY)) { 90 | $this->fakeVisibility[$path] = \strval($visibility); 91 | } 92 | } catch (Throwable $e) { 93 | throw UnableToWriteFile::atLocation($location, '', $e); 94 | } finally { 95 | if (isset($stream)) { 96 | \fclose($stream); 97 | } 98 | } 99 | } 100 | 101 | public function read(string $path): string 102 | { 103 | try { 104 | $stream = $this->readStream($path); 105 | $contents = \stream_get_contents($stream); 106 | } catch (Throwable $e) { 107 | throw UnableToReadFile::fromLocation($path, $e->getMessage(), $e); 108 | } finally { 109 | if (isset($stream)) { 110 | \fclose($stream); 111 | } 112 | } 113 | 114 | if ($contents === false) { 115 | throw UnableToReadFile::fromLocation($path); 116 | } 117 | 118 | return $contents; 119 | } 120 | 121 | public function readStream(string $path) 122 | { 123 | $location = $this->prefixer->prefixPath($path); 124 | 125 | try { 126 | $stream = $this->share->read($location); 127 | } catch (Throwable $e) { 128 | throw UnableToReadFile::fromLocation($location, '', $e); 129 | } 130 | 131 | return $stream; 132 | } 133 | 134 | public function delete(string $path): void 135 | { 136 | $location = $this->prefixer->prefixPath($path); 137 | 138 | try { 139 | $this->share->del($location); 140 | } catch (NotFoundException) { 141 | // We should ignore exceptions if the file did not exist in the first place. 142 | } catch (Throwable $e) { 143 | throw UnableToDeleteFile::atLocation($location, $e->getMessage(), $e); 144 | } 145 | } 146 | 147 | public function deleteDirectory(string $path): void 148 | { 149 | $location = $this->prefixer->prefixPath($path); 150 | 151 | try { 152 | $this->share->rmdir($location); 153 | } catch (Throwable $e) { 154 | throw UnableToDeleteDirectory::atLocation($location, $e->getMessage(), $e); 155 | } 156 | } 157 | 158 | public function createDirectory(string $path, Config $config): void 159 | { 160 | try { 161 | $this->recursiveCreateDir($path); 162 | } catch (Throwable $e) { 163 | throw UnableToCreateDirectory::dueToFailure($path, $e); 164 | } 165 | } 166 | 167 | public function setVisibility(string $path, string $visibility): void 168 | { 169 | if (! $this->fileExists($path)) { 170 | throw UnableToSetVisibility::atLocation($path, 'File does not exist'); 171 | } 172 | 173 | $this->fakeVisibility[$path] = $visibility; 174 | } 175 | 176 | public function visibility(string $path): FileAttributes 177 | { 178 | return $this->getFileAttributes($path); 179 | } 180 | 181 | public function mimeType(string $path): FileAttributes 182 | { 183 | try { 184 | $resource = $this->readStream($path); 185 | } catch (Throwable $e) { 186 | throw UnableToRetrieveMetadata::mimeType($path, $e->getMessage(), $e); 187 | } 188 | 189 | $mimeType = $this->mimeTypeDetector->detectMimeType($path, $resource); 190 | \fclose($resource); 191 | 192 | if ($mimeType === null) { 193 | throw UnableToRetrieveMetadata::mimeType($path, \error_get_last()['message'] ?? ''); 194 | } 195 | 196 | return new FileAttributes($path, null, null, null, $mimeType); 197 | } 198 | 199 | public function lastModified(string $path): FileAttributes 200 | { 201 | return $this->getFileAttributes($path); 202 | } 203 | 204 | public function fileSize(string $path): FileAttributes 205 | { 206 | return $this->getFileAttributes($path); 207 | } 208 | 209 | public function listContents(string $path, bool $deep): iterable 210 | { 211 | foreach ($this->share->dir($path) as $fileInfo) { 212 | if ($fileInfo->isDirectory()) { 213 | yield new DirectoryAttributes($fileInfo->getPath(), $this->fakeVisibility[$fileInfo->getPath()] ?? null, $fileInfo->getMTime()); 214 | if ($deep) { 215 | foreach ($this->listContents($fileInfo->getPath(), true) as $deepFileInfo) { 216 | yield $deepFileInfo; 217 | } 218 | } 219 | } else { 220 | yield new FileAttributes($fileInfo->getPath(), $fileInfo->getSize(), $this->fakeVisibility[$fileInfo->getPath()] ?? null, $fileInfo->getMTime()); 221 | } 222 | } 223 | } 224 | 225 | public function move(string $source, string $destination, Config $config): void 226 | { 227 | try { 228 | $this->share->rename($source, $destination); 229 | } catch (Throwable $e) { 230 | throw UnableToMoveFile::fromLocationTo($source, $destination, $e); 231 | } 232 | } 233 | 234 | public function copy(string $source, string $destination, Config $config): void 235 | { 236 | $content = $this->read($source); 237 | $this->write($destination, $content, $config); 238 | 239 | $this->fakeVisibility[$destination] = \strval($config->get(Config::OPTION_VISIBILITY, $this->fakeVisibility[$source] ?? null)); 240 | } 241 | 242 | /** Recursively remove all data from a folder */ 243 | public function clearDir(string $path): void 244 | { 245 | foreach ($this->listContents($path, false) as $content) { 246 | if ($content instanceof DirectoryAttributes) { 247 | $this->clearDir($content->path()); 248 | $this->deleteDirectory($content->path()); 249 | } else { 250 | $this->delete($content->path()); 251 | } 252 | } 253 | } 254 | 255 | protected function recursiveCreateDir(string $path): void 256 | { 257 | if ($path === '.' || $this->directoryExists($path)) { 258 | return; 259 | } 260 | 261 | $directories = \explode(DIRECTORY_SEPARATOR, $path); 262 | if (\count($directories) > 1) { 263 | $parentDirectories = \array_splice($directories, 0, \count($directories) - 1); 264 | $this->recursiveCreateDir(\implode(DIRECTORY_SEPARATOR, $parentDirectories)); 265 | } 266 | 267 | $location = $this->prefixer->prefixPath($path); 268 | 269 | $this->share->mkdir($location); 270 | } 271 | 272 | protected function getFileAttributes(string $path): FileAttributes 273 | { 274 | $location = $this->prefixer->prefixPath($path); 275 | 276 | try { 277 | $fileInfo = $this->share->stat($location); 278 | } catch (Throwable $e) { 279 | throw UnableToRetrieveMetadata::lastModified($location, '', $e); 280 | } 281 | 282 | if ($fileInfo->isDirectory()) { 283 | throw UnableToRetrieveMetadata::lastModified($location, "'{$path}' is a directory"); 284 | } 285 | 286 | return new FileAttributes( 287 | $location, 288 | $fileInfo->getSize(), 289 | $this->fakeVisibility[$path] ?? null, 290 | $fileInfo->getMTime(), 291 | ); 292 | } 293 | } 294 | --------------------------------------------------------------------------------