diff --git a/webdav_storage/composer.json b/webdav_storage/composer.json new file mode 100644 index 00000000..17a4ba96 --- /dev/null +++ b/webdav_storage/composer.json @@ -0,0 +1,22 @@ +{ + "name": "friendica-addons/webdav_storage", + "description": "Adds the possibility to use WebDAV as a selectable storage backend", + "type": "friendica-addon", + "authors": [ + { + "name": "Philipp Holzer", + "email": "admin@philipp.info", + "homepage": "https://blog.philipp.info", + "role": "Developer" + } + ], + "require": { + "php": ">=7.0" + }, + "license": "3-clause BSD license", + "config": { + "optimize-autoloader": true, + "autoloader-suffix": "WebDavStorageAddon", + "preferred-install": "dist" + } +} diff --git a/webdav_storage/src/WebDav.php b/webdav_storage/src/WebDav.php new file mode 100644 index 00000000..94094bfd --- /dev/null +++ b/webdav_storage/src/WebDav.php @@ -0,0 +1,369 @@ +l10n = $l10n; + $this->config = $config; + $this->client = $client; + $this->logger = $logger; + + $this->authOptions = null; + + if (!empty($this->config->get('webdav', 'username'))) { + $this->authOptions = [ + $this->config->get('webdav', 'username'), + (string)$this->config->get('webdav', 'password', ''), + $this->config->get('webdav', 'auth_type', 'basic') + ]; + } + + $this->url = $this->config->get('webdav', 'url'); + } + + + /** + * Split data ref and return file path + * + * @param string $reference Data reference + * + * @return string[] + */ + private function pathForRef(string $reference): array + { + $fold1 = substr($reference, 0, 2); + $fold2 = substr($reference, 2, 2); + $file = substr($reference, 4); + + return [$this->encodePath(implode('/', [$fold1, $fold2, $file])), implode('/', [$fold1, $fold2]), $file]; + } + + /** + * URL encodes the given path but keeps the slashes + * + * @param string $path to encode + * + * @return string encoded path + */ + protected function encodePath(string $path): string + { + // slashes need to stay + return str_replace('%2F', '/', rawurlencode($path)); + } + + /** + * Checks if the URL exists + * + * @param string $uri the URL to check + * + * @return bool true in case the file/folder exists + */ + protected function exists(string $uri): bool + { + return $this->client->head($uri, [HTTPClientOptions::AUTH => $this->authOptions])->getReturnCode() == 200; + } + + /** + * Checks if a folder has items left + * + * @param string $uri the URL to check + * + * @return bool true in case there are items left in the folder + */ + protected function hasItems(string $uri): bool + { + $dom = new \DOMDocument('1.0', 'UTF-8'); + $dom->formatOutput = true; + $root = $dom->createElementNS('DAV:', 'd:propfind'); + $prop = $dom->createElement('d:allprop'); + + $dom->appendChild($root)->appendChild($prop); + + $opts = [ + HTTPClientOptions::AUTH => $this->authOptions, + HTTPClientOptions::HEADERS => ['Depth' => 1, 'Prefer' => 'return-minimal', 'Content-Type' => 'application/xml'], + HTTPClientOptions::BODY => $dom->saveXML(), + ]; + + $response = $this->client->request('propfind', $uri, $opts); + + $responseDoc = new \DOMDocument(); + $responseDoc->loadXML($response->getBody()); + $responseDoc->formatOutput = true; + + $xpath = new \DOMXPath($responseDoc); + $xpath->registerNamespace('d', 'DAV'); + $result = $xpath->query('//d:multistatus/d:response'); + + // returns at least its own directory, so >1 + return $result !== false && count($result) > 1; + } + + /** + * Creates a DAV-collection (= folder) for the given uri + * + * @param string $uri The uri for creating a DAV-collection + * + * @return bool true in case the creation was successful (not immutable!) + */ + protected function mkcol(string $uri): bool + { + return $this->client->request('mkcol', $uri, [HTTPClientOptions::AUTH => $this->authOptions]) + ->getReturnCode() == 200; + } + + /** + * Checks if the given path exists and if not creates it + * + * @param string $fullPath the full path (the folder structure after the hostname) + */ + protected function checkAndCreatePath(string $fullPath): void + { + $finalUrl = $this->url . '/' . trim($fullPath, '/'); + + if ($this->exists($finalUrl)) { + return; + } + + $pathParts = explode('/', trim($fullPath, '/')); + $path = ''; + + foreach ($pathParts as $part) { + $path .= '/' . $part; + $partUrl = $this->url . $path; + if (!$this->exists($partUrl)) { + $this->mkcol($partUrl); + } + } + } + + /** + * Checks recursively, if paths are empty and deletes them + * + * @param string $fullPath the full path (the folder structure after the hostname) + * + * @throws StorageException In case a directory cannot get deleted + */ + protected function checkAndDeletePath(string $fullPath): void + { + $pathParts = explode('/', trim($fullPath, '/')); + $partURL = '/' . implode('/', $pathParts); + + foreach ($pathParts as $pathPart) { + $checkUrl = $this->url . $partURL; + if (!empty($partURL) && !$this->hasItems($checkUrl)) { + $response = $this->client->request('delete', $checkUrl, [HTTPClientOptions::AUTH => $this->authOptions]); + + if (!$response->isSuccess()) { + if ($response->getReturnCode() == "404") { + $this->logger->warning('Directory already deleted.', ['uri' => $checkUrl]); + } else { + throw new StorageException(sprintf('Unpredicted error for %s: %s', $checkUrl, $response->getError()), $response->getReturnCode()); + } + } + } + + $partURL = substr($partURL, 0, -strlen('/' . $pathPart)); + } + } + + /** + * {@inheritDoc} + */ + public function get(string $reference): string + { + $file = $this->pathForRef($reference); + + $response = $this->client->request('get', $this->url . '/' . $file[0], [HTTPClientOptions::AUTH => $this->authOptions]); + + if (!$response->isSuccess()) { + throw new ReferenceStorageException(sprintf('Invalid reference %s', $reference)); + } + + return $response->getBody(); + } + + /** + * {@inheritDoc} + */ + public function put(string $data, string $reference = ""): string + { + if ($reference === '') { + try { + $reference = Strings::getRandomHex(); + } catch (Exception $exception) { + throw new StorageException('Webdav storage failed to generate a random hex', $exception->getCode(), $exception); + } + } + $file = $this->pathForRef($reference); + + $this->checkAndCreatePath($file[1]); + + $opts = [ + HTTPClientOptions::BODY => $data, + HTTPClientOptions::AUTH => $this->authOptions, + ]; + + $this->client->request('put', $this->url . '/' . $file[0], $opts); + + return $reference; + } + + /** + * {@inheritDoc} + */ + public function delete(string $reference) + { + $file = $this->pathForRef($reference); + + $response = $this->client->request('delete', $this->url . '/' . $file[0], [HTTPClientOptions::AUTH => $this->authOptions]); + + if (!$response->isSuccess()) { + throw new ReferenceStorageException(sprintf('Invalid reference %s', $reference)); + } + + $this->checkAndDeletePath($file[1]); + } + + /** + * @inheritDoc + */ + public function getOptions(): array + { + $auths = [ + '' => 'None', + 'basic' => 'Basic', + 'digest' => 'Digest', + ]; + + return [ + 'url' => [ + 'input', + $this->l10n->t('URL'), + $this->url, + $this->l10n->t('URL to the Webdav endpoint, where files can be saved'), + true + ], + 'username' => [ + 'input', + $this->l10n->t('Username'), + $this->config->get('webdav', 'username', ''), + $this->l10n->t('Username to authenticate to the Webdav endpoint') + ], + 'password' => [ + 'password', + $this->l10n->t('Password'), + $this->config->get('webdav', 'username', ''), + $this->l10n->t('Password to authenticate to the Webdav endpoint') + ], + 'auth_type' => [ + 'select', + $this->l10n->t('Authentication type'), + $this->config->get('webdav', 'auth_type', ''), + $this->l10n->t('authentication type to the Webdav endpoint'), + $auths, + ] + ]; + } + + /** + * @inheritDoc + */ + public function saveOptions(array $data): array + { + $url = $data['url'] ?? ''; + $username = $data['username'] ?? ''; + $password = $data['password'] ?? ''; + + $auths = [ + '' => 'None', + 'basic' => 'Basic', + 'digest' => 'Digest', + ]; + + $authType = $data['auth_type'] ?? ''; + if (!key_exists($authType, $auths)) { + return [ + 'auth_type' => $this->l10n->t('Authentication type is invalid.'), + ]; + } + + $options = null; + + if (!empty($username)) { + $options = [ + $username, + $password, + $authType + ]; + } + + if (!$this->client->head($url, [HTTPClientOptions::AUTH => $options])->isSuccess()) { + return [ + 'url' => $this->l10n->t('url is either invalid or not reachable'), + ]; + } + + $this->config->set('webdav', 'url', $url); + $this->config->set('webdav', 'username', $username); + $this->config->set('webdav', 'password', $password); + $this->config->set('webdav', 'auth_type', $authType); + + $this->url = $url; + + return []; + } + + /** + * {@inheritDoc} + */ + public function __toString() + { + return self::getName(); + } + + /** + * {@inheritDoc} + */ + public static function getName(): string + { + return self::NAME; + } +} diff --git a/webdav_storage/tests/WebDavTest.php b/webdav_storage/tests/WebDavTest.php new file mode 100644 index 00000000..b9deb255 --- /dev/null +++ b/webdav_storage/tests/WebDavTest.php @@ -0,0 +1,98 @@ + [ + 'xml' => << + + + /remote.php/dav/files/admin/Friendica_test/97/18/ + + + Mon, 30 Aug 2021 12:58:54 GMT + + + + 45017 + 59180834349 + "612cd60ec9fd5" + + HTTP/1.1 200 OK + + + + + /remote.php/dav/files/admin/Friendica_test/97/18/4d9d36f614dc005756bdfb9abbf1d8d24aa9ae842e5d6b5e7eb1dafbe767 + + + + Mon, 30 Aug 2021 12:58:54 GMT + 45017 + + "4f7a144092532141d0e6b925e50a896e" + application/octet-stream + + + HTTP/1.1 200 OK + + + + + + + HTTP/1.1 404 Not Found + + + + +EOF, + 'assertionCount' => 2, + ], + 'onlyDir' => [ + 'xml' => << + + /remote.php/dav/files/admin/Friendica_test/34/cf/ + + + Sun, 05 Sep 2021 17:56:05 GMT + + + + 0 + 59182800697 + "613504b55db4f" + + HTTP/1.1 200 OK + + + +EOF, + 'assertionCount' => 1, + ], + ]; + } + + /** + * @dataProvider dataMultiStatus + */ + public function testMultistatus(string $xml, int $assertionCount) + { + $responseDoc = new \DOMDocument(); + $responseDoc->loadXML($xml); + + $xpath = new \DOMXPath($responseDoc); + $xpath->registerNamespace('d', 'DAV'); + + self::assertCount($assertionCount, $xpath->query('//d:multistatus/d:response')); + } +} diff --git a/webdav_storage/webdav_storage.php b/webdav_storage/webdav_storage.php new file mode 100644 index 00000000..422a5041 --- /dev/null +++ b/webdav_storage/webdav_storage.php @@ -0,0 +1,28 @@ +register(WebDav::class); +} + +function webdav_storage_uninstall() +{ + DI::storageManager()->unregister(WebDav::getName()); +} + +function webdav_storage_instance(App $a, array &$data) +{ + $data['storage'] = new WebDav(DI::l10n(), DI::config(), DI::httpClient(), DI::logger()); +}