From 54ae1cfb760e77c9696b63b3771098813843933c Mon Sep 17 00:00:00 2001 From: Frank de Jonge Date: Sat, 12 Feb 2022 20:31:29 +0100 Subject: [PATCH] Initial implementation of the WebDAV adapter. --- .dockerignore | 1 + .gitattributes | 1 + .github/workflows/quality-assurance.yml | 1 + composer.json | 2 +- docker-compose.yml | 10 +- .../AzureBlobStorageAdapter.php | 6 +- src/DirectoryAttributes.php | 2 +- src/FileAttributes.php | 2 +- src/WebDAV/ByteMarkWebDAVServerTest.php | 18 + src/WebDAV/SabreServerTest.php | 18 + src/WebDAV/WebDAVAdapter.php | 440 ++++++++++++++++++ src/WebDAV/WebDAVAdapterTestCase.php | 124 +++++ src/WebDAV/resources/.gitignore | 2 + src/WebDAV/resources/server.php | 24 + 14 files changed, 642 insertions(+), 9 deletions(-) create mode 100644 .dockerignore create mode 100644 src/WebDAV/ByteMarkWebDAVServerTest.php create mode 100644 src/WebDAV/SabreServerTest.php create mode 100644 src/WebDAV/WebDAVAdapter.php create mode 100644 src/WebDAV/WebDAVAdapterTestCase.php create mode 100644 src/WebDAV/resources/.gitignore create mode 100644 src/WebDAV/resources/server.php diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..6b8710a71 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.git diff --git a/.gitattributes b/.gitattributes index 099507c57..04ecf094b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,6 +22,7 @@ /src/AdapterTestUtilities export-ignore /src/AzureBlobStorage export-ignore /src/ZipArchive export-ignore +/src/WebDAV export-ignore /.gitattributes export-ignore /.gitignore export-ignore /bin/ export-ignore diff --git a/.github/workflows/quality-assurance.yml b/.github/workflows/quality-assurance.yml index c38ad604d..877abe850 100644 --- a/.github/workflows/quality-assurance.yml +++ b/.github/workflows/quality-assurance.yml @@ -7,6 +7,7 @@ on: - .github/workflows/quality-assurance.yml branches: - 3.x + - 3.x-sabre pull_request: paths: - src/**/*.php diff --git a/composer.json b/composer.json index 14aa162ec..f8732268c 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,7 @@ "google/cloud-storage": "^1.23", "async-aws/s3": "^1.5", "async-aws/simple-s3": "^1.0", - "sabre/dav": "^4.1" + "sabre/dav": "^4.3" }, "conflict": { "symfony/http-client": "<5.2", diff --git a/docker-compose.yml b/docker-compose.yml index 4e1b723b1..44742374b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,19 @@ --- version: "3" services: + sabredav: + image: php:8.1-alpine3.15 + restart: always + volumes: + - ./:/var/www/html/ + ports: + - "4040:4040" + command: php -S 0.0.0.0:4040 /var/www/html/src/WebDAV/resources/server.php webdav: image: bytemark/webdav restart: always ports: - - "80:80" + - "4080:80" environment: AUTH_TYPE: Digest USERNAME: alice diff --git a/src/AzureBlobStorage/AzureBlobStorageAdapter.php b/src/AzureBlobStorage/AzureBlobStorageAdapter.php index 89283add0..8d15de10d 100644 --- a/src/AzureBlobStorage/AzureBlobStorageAdapter.php +++ b/src/AzureBlobStorage/AzureBlobStorageAdapter.php @@ -223,11 +223,7 @@ public function setVisibility(string $path, string $visibility): void public function visibility(string $path): FileAttributes { - try { - return $this->fetchMetadata($this->prefixer->prefixPath($path)); - } catch (Throwable $exception) { - throw UnableToRetrieveMetadata::visibility($path, '', $exception); - } + throw UnableToRetrieveMetadata::visibility($path, 'Azure does not support visibility'); } public function mimeType(string $path): FileAttributes diff --git a/src/DirectoryAttributes.php b/src/DirectoryAttributes.php index 94e62189e..e07c36dec 100644 --- a/src/DirectoryAttributes.php +++ b/src/DirectoryAttributes.php @@ -35,7 +35,7 @@ class DirectoryAttributes implements StorageAttributes public function __construct(string $path, ?string $visibility = null, ?int $lastModified = null, array $extraMetadata = []) { - $this->path = $path; + $this->path = trim($path, '/'); $this->visibility = $visibility; $this->lastModified = $lastModified; $this->extraMetadata = $extraMetadata; diff --git a/src/FileAttributes.php b/src/FileAttributes.php index 2efd9c4d2..342be3aa7 100644 --- a/src/FileAttributes.php +++ b/src/FileAttributes.php @@ -51,7 +51,7 @@ public function __construct( ?string $mimeType = null, array $extraMetadata = [] ) { - $this->path = $path; + $this->path = ltrim($path, '/'); $this->fileSize = $fileSize; $this->visibility = $visibility; $this->lastModified = $lastModified; diff --git a/src/WebDAV/ByteMarkWebDAVServerTest.php b/src/WebDAV/ByteMarkWebDAVServerTest.php new file mode 100644 index 000000000..a66ae17e6 --- /dev/null +++ b/src/WebDAV/ByteMarkWebDAVServerTest.php @@ -0,0 +1,18 @@ + 'http://localhost:4080/', 'userName' => 'alice', 'password' => 'secret1234']); + + return new WebDAVAdapter($client, manualCopy: true, manualMove: true); + } +} diff --git a/src/WebDAV/SabreServerTest.php b/src/WebDAV/SabreServerTest.php new file mode 100644 index 000000000..913dac9aa --- /dev/null +++ b/src/WebDAV/SabreServerTest.php @@ -0,0 +1,18 @@ + 'http://localhost:4040/']); + + return new WebDAVAdapter($client); + } +} diff --git a/src/WebDAV/WebDAVAdapter.php b/src/WebDAV/WebDAVAdapter.php new file mode 100644 index 000000000..491cca025 --- /dev/null +++ b/src/WebDAV/WebDAVAdapter.php @@ -0,0 +1,440 @@ +prefixer = new PathPrefixer($prefix); + } + + public function fileExists(string $path): bool + { + $location = $this->prefixer->prefixPath($this->encodePath($path)); + + try { + $properties = $this->client->propFind($location, ['{DAV:}resourcetype', '{DAV:}iscollection']); + + return ! $this->propsIsDirectory($properties); + } catch (Throwable $exception) { + if ($exception instanceof ClientHttpException && $exception->getHttpStatus() === 404) { + return false; + } + + throw UnableToCheckFileExistence::forLocation($path, $exception); + } + } + + protected function encodePath(string $path): string + { + $parts = explode('/', $path); + + foreach ($parts as $i => $part) { + $parts[$i] = rawurlencode($part); + } + + return implode('/', $parts); + } + + public function directoryExists(string $path): bool + { + $location = $this->prefixer->prefixPath($this->encodePath($path)); + + try { + $properties = $this->client->propFind($location, ['{DAV:}resourcetype', '{DAV:}iscollection']); + + return $this->propsIsDirectory($properties); + } catch (Throwable $exception) { + if ($exception instanceof ClientHttpException && $exception->getHttpStatus() === 404) { + return false; + } + + throw UnableToCheckDirectoryExistence::forLocation($path, $exception); + } + } + + public function write(string $path, string $contents, Config $config): void + { + $this->upload($path, $contents); + } + + public function writeStream(string $path, $contents, Config $config): void + { + $this->upload($path, $contents); + } + + /** + * @param resource|string $contents + */ + private function upload(string $path, mixed $contents): void + { + $this->createParentDirFor($path); + $location = $this->prefixer->prefixPath($this->encodePath($path)); + + try { + $response = $this->client->request('PUT', $location, $contents); + $statusCode = $response['statusCode']; + + if ($statusCode < 200 || $statusCode >= 300) { + throw new RuntimeException('Unexpected status code received: ' . $statusCode); + } + } catch (Throwable $exception) { + throw UnableToWriteFile::atLocation($path, '', $exception); + } + } + + public function read(string $path): string + { + $location = $this->prefixer->prefixPath($this->encodePath($path)); + + try { + $response = $this->client->request('GET', $location); + + if ($response['statusCode'] !== 200) { + throw new RuntimeException('Unexpected response code for GET: ' . $response['statusCode']); + } + + return $response['body']; + } catch (Throwable $exception) { + throw UnableToReadFile::fromLocation($path, '', $exception); + } + } + + public function readStream(string $path) + { + $location = $this->prefixer->prefixPath($this->encodePath($path)); + + try { + $url = $this->client->getAbsoluteUrl($location); + $request = new Request('GET', $url); + $response = $this->client->send($request); + $status = $response->getStatus(); + + if ($status !== 200) { + throw new RuntimeException('Unexpected response code for GET: ' . $status); + } + + return $response->getBodyAsStream(); + } catch (Throwable $exception) { + throw UnableToReadFile::fromLocation($path, '', $exception); + } + } + + public function delete(string $path): void + { + $location = $this->prefixer->prefixPath($this->encodePath($path)); + + try { + $response = $this->client->request('DELETE', $location); + $statusCode = $response['statusCode']; + + if ($statusCode !== 404 && ($statusCode < 200 || $statusCode >= 300)) { + throw new RuntimeException('Unexpected status code received while deleting file: ' . $statusCode); + } + } catch (Throwable $exception) { + if ( ! ($exception instanceof ClientHttpException && $exception->getCode() === 404)) { + throw UnableToDeleteFile::atLocation($path, '', $exception); + } + } + } + + public function deleteDirectory(string $path): void + { + $location = $this->prefixer->prefixDirectoryPath($this->encodePath($path)); + + try { + $statusCode = $this->client->request('DELETE', $location)['statusCode']; + + if ($statusCode !== 404 && ($statusCode < 200 || $statusCode >= 300)) { + throw new RuntimeException('Unexpected status code received while deleting file: ' . $statusCode); + } + } catch (Throwable $exception) { + if ( ! ($exception instanceof ClientHttpException && $exception->getCode() === 404)) { + throw UnableToDeleteDirectory::atLocation($path, '', $exception); + } + } + } + + public function createDirectory(string $path, Config $config): void + { + $parts = explode('/', $path); + $directoryParts = []; + + foreach ($parts as $directory) { + $directoryParts[] = $directory; + $directoryPath = implode('/', $directoryParts); + $location = $this->prefixer->prefixDirectoryPath($this->encodePath($directoryPath)); + + if ($this->directoryExists($directoryPath)) { + continue; + } + + try { + $response = $this->client->request('MKCOL', $location); + } catch (Throwable $exception) { + throw UnableToCreateDirectory::dueToFailure($path, $exception); + } + + if ($response['statusCode'] !== 201) { + throw UnableToCreateDirectory::atLocation($path, 'Failed to create directory at: ' . $location); + } + } + } + + public function setVisibility(string $path, string $visibility): void + { + if ($this->visibilityHandling === self::ON_VISIBILITY_THROW_ERROR) { + throw UnableToSetVisibility::atLocation($path, 'WebDAV does not support this operation.'); + } + } + + public function visibility(string $path): FileAttributes + { + throw UnableToRetrieveMetadata::visibility($path, 'WebDAV does not support this operation.'); + } + + public function mimeType(string $path): FileAttributes + { + $mimeType = (string) $this->propFind($path, 'mime_type', '{DAV:}getcontenttype'); + + return new FileAttributes($path, mimeType: $mimeType); + } + + public function lastModified(string $path): FileAttributes + { + $lastModified = $this->propFind($path, 'last_modified', '{DAV:}getlastmodified'); + + return new FileAttributes($path, lastModified: strtotime($lastModified)); + } + + public function fileSize(string $path): FileAttributes + { + $fileSize = (int) $this->propFind($path, 'file_size', '{DAV:}getcontentlength'); + + return new FileAttributes($path, fileSize: $fileSize); + } + + public function listContents(string $path, bool $deep): iterable + { + $location = $this->prefixer->prefixDirectoryPath($this->encodePath($path)); + $response = $this->client->propFind($location, self::FIND_PROPERTIES, 1); + array_shift($response); + + foreach ($response as $path => $object) { + $path = $this->prefixer->stripPrefix(rawurldecode($path)); + $object = $this->normalizeObject($object); + + if ($this->propsIsDirectory($object)) { + yield new DirectoryAttributes($path, lastModified: $object['last_modified'] ?? null); + + if ( ! $deep) { + continue; + } + + foreach ($this->listContents($path, true) as $child) { + yield $child; + } + } else { + yield new FileAttributes( + $path, + fileSize: $object['file_size'] ?? null, + lastModified: $object['last_modified'] ?? null, + mimeType: $object['mime_type'] ?? null, + ); + } + } + } + + private function normalizeObject(array $object): array + { + $mapping = [ + '{DAV:}getcontentlength' => 'file_size', + '{DAV:}getcontenttype' => 'mime_type', + 'content-length' => 'file_size', + 'content-type' => 'mime_type', + ]; + + foreach ($mapping as $from => $to) { + if (array_key_exists($from, $object)) { + $object[$to] = $object[$from]; + } + } + + array_key_exists('file_size', $object) && $object['file_size'] = (int) $object['file_size']; + + if (array_key_exists('{DAV:}getlastmodified', $object)) { + $object['last_modified'] = strtotime($object['{DAV:}getlastmodified']); + } + + return $object; + } + + public function move(string $source, string $destination, Config $config): void + { + if ($this->manualMove) { + $this->manualMove($source, $destination); + return; + } + + $this->createParentDirFor($destination); + $location = $this->prefixer->prefixPath($this->encodePath($source)); + $newLocation = $this->prefixer->prefixPath($this->encodePath($destination)); + + try { + $response = $this->client->request('MOVE', '/' . ltrim($location, '/'), null, [ + 'Destination' => '/' . ltrim($newLocation, '/'), + ]); + + if ($response['statusCode'] < 200 || $response['statusCode'] >= 300) { + throw new RuntimeException('MOVE command returned unexpected status code: ' . $response['statusCode'] . "\n{$response['body']}"); + } + } catch (Throwable $e) { + throw UnableToMoveFile::fromLocationTo($source, $destination, $e); + } + } + + private function manualMove(string $source, string $destination): void + { + try { + $handle = $this->readStream($source); + $this->writeStream($destination, $handle, new Config()); + @fclose($handle); + $this->delete($source); + } catch (Throwable $exception) { + throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); + } + } + + public function copy(string $source, string $destination, Config $config): void + { + if ($this->manualCopy) { + $this->manualCopy($source, $destination); + return; + } + + $this->createParentDirFor($destination); + $location = $this->prefixer->prefixPath($this->encodePath($source)); + $newLocation = $this->prefixer->prefixPath($this->encodePath($destination)); + + try { + $response = $this->client->request('COPY', '/' . ltrim($location, '/'), null, [ + 'Destination' => '/' . ltrim($newLocation, '/'), + ]); + + if ($response['statusCode'] < 200 || $response['statusCode'] >= 300) { + throw new RuntimeException('COPY command returned unexpected status code: ' . $response['statusCode']); + } + } catch (Throwable $e) { + throw UnableToCopyFile::fromLocationTo($source, $destination, $e); + } + } + + private function manualCopy(string $source, string $destination): void + { + try { + $handle = $this->readStream($source); + $this->writeStream($destination, $handle, new Config()); + @fclose($handle); + } catch (Throwable $exception) { + throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); + } + } + + private function propsIsDirectory(array $properties): bool + { + if (isset($properties['{DAV:}resourcetype'])) { + /** @var ResourceType $resourceType */ + $resourceType = $properties['{DAV:}resourcetype']; + + return $resourceType->is('{DAV:}collection'); + } + + return isset($properties['{DAV:}iscollection']) && $properties['{DAV:}iscollection'] === '1'; + } + + private function createParentDirFor(string $path): void + { + $dirname = dirname($path); + + if ($dirname === '.' || $dirname === '') { + return; + } + + if ($this->directoryExists($dirname)) { + return; + } + + $this->createDirectory($dirname, new Config()); + } + + private function propFind(string $path, string $section, string $property): mixed + { + $location = $this->prefixer->prefixPath($path); + + try { + $result = $this->client->propFind($location, [$property]); + + if ( ! array_key_exists($property, $result)) { + throw new RuntimeException('Invalid response, missing key: ' . $property); + } + + return $result[$property]; + } catch (Throwable $exception) { + throw UnableToRetrieveMetadata::create($path, $section, '', $exception); + } + } +} diff --git a/src/WebDAV/WebDAVAdapterTestCase.php b/src/WebDAV/WebDAVAdapterTestCase.php new file mode 100644 index 000000000..c1a8233f6 --- /dev/null +++ b/src/WebDAV/WebDAVAdapterTestCase.php @@ -0,0 +1,124 @@ +adapter(); + $this->givenWeHaveAnExistingFile('some/file.txt'); + + $this->expectException(UnableToSetVisibility::class); + + $adapter->setVisibility('some/file.txt', Visibility::PRIVATE); + } + + /** + * @test + */ + public function overwriting_a_file(): void + { + $this->runScenario(function () { + $this->givenWeHaveAnExistingFile('path.txt', 'contents'); + $adapter = $this->adapter(); + + $adapter->write('path.txt', 'new contents', new Config()); + + $contents = $adapter->read('path.txt'); + $this->assertEquals('new contents', $contents); + }); + } + + /** + * @test + */ + public function copying_a_file(): void + { + $this->runScenario(function () { + $adapter = $this->adapter(); + $adapter->write( + 'source.txt', + 'contents to be copied', + new Config() + ); + + $adapter->copy('source.txt', 'destination.txt', new Config()); + + $this->assertTrue($adapter->fileExists('source.txt')); + $this->assertTrue($adapter->fileExists('destination.txt')); + $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); + }); + } + + /** + * @test + */ + public function copying_a_file_again(): void + { + $this->runScenario(function () { + $adapter = $this->adapter(); + $adapter->write( + 'source.txt', + 'contents to be copied', + new Config() + ); + + $adapter->copy('source.txt', 'destination.txt', new Config()); + + $this->assertTrue($adapter->fileExists('source.txt')); + $this->assertTrue($adapter->fileExists('destination.txt')); + $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); + }); + } + + /** + * @test + */ + public function moving_a_file(): void + { + $this->runScenario(function () { + $adapter = $this->adapter(); + $adapter->write( + 'source.txt', + 'contents to be copied', + new Config() + ); + $adapter->move('source.txt', 'destination.txt', new Config()); + $this->assertFalse( + $adapter->fileExists('source.txt'), + 'After moving a file should no longer exist in the original location.' + ); + $this->assertTrue( + $adapter->fileExists('destination.txt'), + 'After moving, a file should be present at the new location.' + ); + $this->assertEquals('contents to be copied', $adapter->read('destination.txt')); + }); + } + + /** + * @test + */ + public function moving_a_file_that_does_not_exist(): void + { + $this->expectException(UnableToMoveFile::class); + + $this->runScenario(function () { + $this->adapter()->move('source.txt', 'destination.txt', new Config()); + }); + } +} diff --git a/src/WebDAV/resources/.gitignore b/src/WebDAV/resources/.gitignore new file mode 100644 index 000000000..daf5f69d3 --- /dev/null +++ b/src/WebDAV/resources/.gitignore @@ -0,0 +1,2 @@ +data +index.php diff --git a/src/WebDAV/resources/server.php b/src/WebDAV/resources/server.php new file mode 100644 index 000000000..5910b76b1 --- /dev/null +++ b/src/WebDAV/resources/server.php @@ -0,0 +1,24 @@ +addPlugin(new Sabre\DAV\Browser\Plugin()); + +if (strpos($_SERVER['REQUEST_URI'], 'unknown-mime-type.md5') === false) { + $guesser = new Sabre\DAV\Browser\GuessContentType(); + $guesser->extensionMap['svg'] = 'image/svg'; + $server->addPlugin($guesser); +} + +$server->start();