├── LICENSE ├── WebDAVAdapter.php └── composer.json /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-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 | -------------------------------------------------------------------------------- /WebDAVAdapter.php: -------------------------------------------------------------------------------- 1 | prefixer = new PathPrefixer($prefix); 63 | } 64 | 65 | public function fileExists(string $path): bool 66 | { 67 | $location = $this->encodePath($this->prefixer->prefixPath($path)); 68 | 69 | try { 70 | $properties = $this->client->propFind($location, ['{DAV:}resourcetype', '{DAV:}iscollection']); 71 | 72 | return ! $this->propsIsDirectory($properties); 73 | } catch (Throwable $exception) { 74 | if ($exception instanceof ClientHttpException && $exception->getHttpStatus() === 404) { 75 | return false; 76 | } 77 | 78 | throw UnableToCheckFileExistence::forLocation($path, $exception); 79 | } 80 | } 81 | 82 | protected function encodePath(string $path): string 83 | { 84 | $parts = explode('/', $path); 85 | 86 | foreach ($parts as $i => $part) { 87 | $parts[$i] = rawurlencode($part); 88 | } 89 | 90 | return implode('/', $parts); 91 | } 92 | 93 | public function directoryExists(string $path): bool 94 | { 95 | $location = $this->encodePath($this->prefixer->prefixPath($path)); 96 | 97 | try { 98 | $properties = $this->client->propFind($location, ['{DAV:}resourcetype', '{DAV:}iscollection']); 99 | 100 | return $this->propsIsDirectory($properties); 101 | } catch (Throwable $exception) { 102 | if ($exception instanceof ClientHttpException && $exception->getHttpStatus() === 404) { 103 | return false; 104 | } 105 | 106 | throw UnableToCheckDirectoryExistence::forLocation($path, $exception); 107 | } 108 | } 109 | 110 | public function write(string $path, string $contents, Config $config): void 111 | { 112 | $this->upload($path, $contents); 113 | } 114 | 115 | public function writeStream(string $path, $contents, Config $config): void 116 | { 117 | $this->upload($path, $contents); 118 | } 119 | 120 | /** 121 | * @param resource|string $contents 122 | */ 123 | private function upload(string $path, mixed $contents): void 124 | { 125 | $this->createParentDirFor($path); 126 | $location = $this->encodePath($this->prefixer->prefixPath($path)); 127 | 128 | try { 129 | $response = $this->client->request('PUT', $location, $contents); 130 | $statusCode = $response['statusCode']; 131 | 132 | if ($statusCode < 200 || $statusCode >= 300) { 133 | throw new RuntimeException('Unexpected status code received: ' . $statusCode); 134 | } 135 | } catch (Throwable $exception) { 136 | throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); 137 | } 138 | } 139 | 140 | public function read(string $path): string 141 | { 142 | $location = $this->encodePath($this->prefixer->prefixPath($path)); 143 | 144 | try { 145 | $response = $this->client->request('GET', $location); 146 | 147 | if ($response['statusCode'] !== 200) { 148 | throw new RuntimeException('Unexpected response code for GET: ' . $response['statusCode']); 149 | } 150 | 151 | return $response['body']; 152 | } catch (Throwable $exception) { 153 | throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); 154 | } 155 | } 156 | 157 | public function readStream(string $path) 158 | { 159 | $location = $this->encodePath($this->prefixer->prefixPath($path)); 160 | 161 | try { 162 | $url = $this->client->getAbsoluteUrl($location); 163 | $request = new Request('GET', $url); 164 | $response = $this->client->send($request); 165 | $status = $response->getStatus(); 166 | 167 | if ($status !== 200) { 168 | throw new RuntimeException('Unexpected response code for GET: ' . $status); 169 | } 170 | 171 | return $response->getBodyAsStream(); 172 | } catch (Throwable $exception) { 173 | throw UnableToReadFile::fromLocation($path, $exception->getMessage(), $exception); 174 | } 175 | } 176 | 177 | public function delete(string $path): void 178 | { 179 | $location = $this->encodePath($this->prefixer->prefixPath($path)); 180 | 181 | try { 182 | $response = $this->client->request('DELETE', $location); 183 | $statusCode = $response['statusCode']; 184 | 185 | if ($statusCode !== 404 && ($statusCode < 200 || $statusCode >= 300)) { 186 | throw new RuntimeException('Unexpected status code received while deleting file: ' . $statusCode); 187 | } 188 | } catch (Throwable $exception) { 189 | if ( ! ($exception instanceof ClientHttpException && $exception->getCode() === 404)) { 190 | throw UnableToDeleteFile::atLocation($path, $exception->getMessage(), $exception); 191 | } 192 | } 193 | } 194 | 195 | public function deleteDirectory(string $path): void 196 | { 197 | $location = $this->encodePath($this->prefixer->prefixDirectoryPath($path)); 198 | 199 | try { 200 | $statusCode = $this->client->request('DELETE', $location)['statusCode']; 201 | 202 | if ($statusCode !== 404 && ($statusCode < 200 || $statusCode >= 300)) { 203 | throw new RuntimeException('Unexpected status code received while deleting file: ' . $statusCode); 204 | } 205 | } catch (Throwable $exception) { 206 | if ( ! ($exception instanceof ClientHttpException && $exception->getCode() === 404)) { 207 | throw UnableToDeleteDirectory::atLocation($path, $exception->getMessage(), $exception); 208 | } 209 | } 210 | } 211 | 212 | public function createDirectory(string $path, Config $config): void 213 | { 214 | $parts = explode('/', $this->prefixer->prefixDirectoryPath($path)); 215 | $directoryParts = []; 216 | 217 | foreach ($parts as $directory) { 218 | if ($directory === '.' || $directory === '') { 219 | return; 220 | } 221 | 222 | $directoryParts[] = $directory; 223 | $directoryPath = implode('/', $directoryParts); 224 | $location = $this->encodePath($directoryPath) . '/'; 225 | 226 | if ($this->directoryExists($this->prefixer->stripDirectoryPrefix($directoryPath))) { 227 | continue; 228 | } 229 | 230 | try { 231 | $response = $this->client->request('MKCOL', $location); 232 | } catch (Throwable $exception) { 233 | throw UnableToCreateDirectory::dueToFailure($path, $exception); 234 | } 235 | 236 | if ($response['statusCode'] === 405) { 237 | continue; 238 | } 239 | 240 | if ($response['statusCode'] !== 201) { 241 | throw UnableToCreateDirectory::atLocation($path, 'Failed to create directory at: ' . $location); 242 | } 243 | } 244 | } 245 | 246 | public function setVisibility(string $path, string $visibility): void 247 | { 248 | if ($this->visibilityHandling === self::ON_VISIBILITY_THROW_ERROR) { 249 | throw UnableToSetVisibility::atLocation($path, 'WebDAV does not support this operation.'); 250 | } 251 | } 252 | 253 | public function visibility(string $path): FileAttributes 254 | { 255 | throw UnableToRetrieveMetadata::visibility($path, 'WebDAV does not support this operation.'); 256 | } 257 | 258 | public function mimeType(string $path): FileAttributes 259 | { 260 | $mimeType = (string) $this->propFind($path, 'mime_type', '{DAV:}getcontenttype'); 261 | 262 | return new FileAttributes($path, mimeType: $mimeType); 263 | } 264 | 265 | public function lastModified(string $path): FileAttributes 266 | { 267 | $lastModified = $this->propFind($path, 'last_modified', '{DAV:}getlastmodified'); 268 | 269 | return new FileAttributes($path, lastModified: strtotime($lastModified)); 270 | } 271 | 272 | public function fileSize(string $path): FileAttributes 273 | { 274 | $fileSize = (int) $this->propFind($path, 'file_size', '{DAV:}getcontentlength'); 275 | 276 | return new FileAttributes($path, fileSize: $fileSize); 277 | } 278 | 279 | public function listContents(string $path, bool $deep): iterable 280 | { 281 | $location = $this->encodePath($this->prefixer->prefixDirectoryPath($path)); 282 | $response = $this->client->propFind($location, self::FIND_PROPERTIES, 1); 283 | 284 | // This is the directory itself, the files are subsequent entries. 285 | array_shift($response); 286 | 287 | foreach ($response as $path => $object) { 288 | $path = (string) parse_url(rawurldecode($path), PHP_URL_PATH); 289 | $path = $this->prefixer->stripPrefix($path); 290 | $object = $this->normalizeObject($object); 291 | 292 | if ($this->propsIsDirectory($object)) { 293 | yield new DirectoryAttributes($path, lastModified: $object['last_modified'] ?? null); 294 | 295 | if ( ! $deep) { 296 | continue; 297 | } 298 | 299 | foreach ($this->listContents($path, true) as $child) { 300 | yield $child; 301 | } 302 | } else { 303 | yield new FileAttributes( 304 | $path, 305 | fileSize: $object['file_size'] ?? null, 306 | lastModified: $object['last_modified'] ?? null, 307 | mimeType: $object['mime_type'] ?? null, 308 | ); 309 | } 310 | } 311 | } 312 | 313 | private function normalizeObject(array $object): array 314 | { 315 | $mapping = [ 316 | '{DAV:}getcontentlength' => 'file_size', 317 | '{DAV:}getcontenttype' => 'mime_type', 318 | 'content-length' => 'file_size', 319 | 'content-type' => 'mime_type', 320 | ]; 321 | 322 | foreach ($mapping as $from => $to) { 323 | if (array_key_exists($from, $object)) { 324 | $object[$to] = $object[$from]; 325 | } 326 | } 327 | 328 | array_key_exists('file_size', $object) && $object['file_size'] = (int) $object['file_size']; 329 | 330 | if (array_key_exists('{DAV:}getlastmodified', $object)) { 331 | $object['last_modified'] = strtotime($object['{DAV:}getlastmodified']); 332 | } 333 | 334 | return $object; 335 | } 336 | 337 | public function move(string $source, string $destination, Config $config): void 338 | { 339 | if ($source === $destination) { 340 | return; 341 | } 342 | 343 | if ($this->manualMove) { 344 | $this->manualMove($source, $destination); 345 | 346 | return; 347 | } 348 | 349 | $this->createParentDirFor($destination); 350 | $location = $this->encodePath($this->prefixer->prefixPath($source)); 351 | $newLocation = $this->encodePath($this->prefixer->prefixPath($destination)); 352 | 353 | try { 354 | $response = $this->client->request('MOVE', $location, null, [ 355 | 'Destination' => $this->client->getAbsoluteUrl($newLocation), 356 | ]); 357 | 358 | if ($response['statusCode'] < 200 || $response['statusCode'] >= 300) { 359 | throw new RuntimeException('MOVE command returned unexpected status code: ' . $response['statusCode'] . "\n{$response['body']}"); 360 | } 361 | } catch (Throwable $e) { 362 | throw UnableToMoveFile::fromLocationTo($source, $destination, $e); 363 | } 364 | } 365 | 366 | private function manualMove(string $source, string $destination): void 367 | { 368 | try { 369 | $handle = $this->readStream($source); 370 | $this->writeStream($destination, $handle, new Config()); 371 | @fclose($handle); 372 | $this->delete($source); 373 | } catch (Throwable $exception) { 374 | throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); 375 | } 376 | } 377 | 378 | public function copy(string $source, string $destination, Config $config): void 379 | { 380 | if ($source === $destination) { 381 | return; 382 | } 383 | 384 | if ($this->manualCopy) { 385 | $this->manualCopy($source, $destination); 386 | 387 | return; 388 | } 389 | 390 | $this->createParentDirFor($destination); 391 | $location = $this->encodePath($this->prefixer->prefixPath($source)); 392 | $newLocation = $this->encodePath($this->prefixer->prefixPath($destination)); 393 | 394 | try { 395 | $response = $this->client->request('COPY', $location, null, [ 396 | 'Destination' => $this->client->getAbsoluteUrl($newLocation), 397 | ]); 398 | 399 | if ($response['statusCode'] < 200 || $response['statusCode'] >= 300) { 400 | throw new RuntimeException('COPY command returned unexpected status code: ' . $response['statusCode']); 401 | } 402 | } catch (Throwable $e) { 403 | throw UnableToCopyFile::fromLocationTo($source, $destination, $e); 404 | } 405 | } 406 | 407 | private function manualCopy(string $source, string $destination): void 408 | { 409 | try { 410 | $handle = $this->readStream($source); 411 | $this->writeStream($destination, $handle, new Config()); 412 | @fclose($handle); 413 | } catch (Throwable $exception) { 414 | throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); 415 | } 416 | } 417 | 418 | private function propsIsDirectory(array $properties): bool 419 | { 420 | if (isset($properties['{DAV:}resourcetype'])) { 421 | /** @var ResourceType $resourceType */ 422 | $resourceType = $properties['{DAV:}resourcetype']; 423 | 424 | return $resourceType->is('{DAV:}collection'); 425 | } 426 | 427 | return isset($properties['{DAV:}iscollection']) && $properties['{DAV:}iscollection'] === '1'; 428 | } 429 | 430 | private function createParentDirFor(string $path): void 431 | { 432 | $dirname = dirname($path); 433 | 434 | if ($this->directoryExists($dirname)) { 435 | return; 436 | } 437 | 438 | $this->createDirectory($dirname, new Config()); 439 | } 440 | 441 | private function propFind(string $path, string $section, string $property): mixed 442 | { 443 | $location = $this->encodePath($this->prefixer->prefixPath($path)); 444 | 445 | try { 446 | $result = $this->client->propFind($location, [$property]); 447 | 448 | if ( ! array_key_exists($property, $result)) { 449 | throw new RuntimeException('Invalid response, missing key: ' . $property); 450 | } 451 | 452 | return $result[$property]; 453 | } catch (Throwable $exception) { 454 | throw UnableToRetrieveMetadata::create($path, $section, $exception->getMessage(), $exception); 455 | } 456 | } 457 | 458 | public function publicUrl(string $path, Config $config): string 459 | { 460 | return $this->client->getAbsoluteUrl($this->encodePath($this->prefixer->prefixPath($path))); 461 | } 462 | } 463 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "league/flysystem-webdav", 3 | "description": "WebDAV filesystem adapter for Flysystem.", 4 | "keywords": ["flysystem", "filesystem", "webdav", "files", "file"], 5 | "autoload": { 6 | "psr-4": { 7 | "League\\Flysystem\\WebDAV\\": "" 8 | } 9 | }, 10 | "require": { 11 | "php": "^8.0.2", 12 | "league/flysystem": "^3.6.0", 13 | "sabre/dav": "^4.6.0" 14 | }, 15 | "license": "MIT", 16 | "authors": [ 17 | { 18 | "name": "Frank de Jonge", 19 | "email": "info@frankdejonge.nl" 20 | } 21 | ] 22 | } 23 | --------------------------------------------------------------------------------