forked from friendica/friendica-addons
WebDav Storage backend
This commit is contained in:
parent
020a1245fd
commit
15d77952ac
4 changed files with 517 additions and 0 deletions
22
webdav_storage/composer.json
Normal file
22
webdav_storage/composer.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
369
webdav_storage/src/WebDav.php
Normal file
369
webdav_storage/src/WebDav.php
Normal file
|
@ -0,0 +1,369 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Friendica\Addon\webdav_storage\src;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
use Friendica\Core\Config\IConfig;
|
||||||
|
use Friendica\Core\L10n;
|
||||||
|
use Friendica\Model\Storage\IWritableStorage;
|
||||||
|
use Friendica\Model\Storage\ReferenceStorageException;
|
||||||
|
use Friendica\Model\Storage\StorageException;
|
||||||
|
use Friendica\Network\HTTPClientOptions;
|
||||||
|
use Friendica\Network\IHTTPClient;
|
||||||
|
use Friendica\Util\Strings;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A WebDav Backend Storage class
|
||||||
|
*/
|
||||||
|
class WebDav implements IWritableStorage
|
||||||
|
{
|
||||||
|
const NAME = 'WebDav';
|
||||||
|
|
||||||
|
/** @var L10n */
|
||||||
|
private $l10n;
|
||||||
|
|
||||||
|
/** @var IConfig */
|
||||||
|
private $config;
|
||||||
|
|
||||||
|
/** @var string */
|
||||||
|
private $url;
|
||||||
|
|
||||||
|
/** @var IHTTPClient */
|
||||||
|
private $client;
|
||||||
|
|
||||||
|
/** @var LoggerInterface */
|
||||||
|
private $logger;
|
||||||
|
|
||||||
|
/** @var array */
|
||||||
|
private $authOptions;
|
||||||
|
|
||||||
|
public function __construct(L10n $l10n, IConfig $config, IHTTPClient $client, LoggerInterface $logger)
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
98
webdav_storage/tests/WebDavTest.php
Normal file
98
webdav_storage/tests/WebDavTest.php
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Friendica\Addon\webdav_storage\tests;
|
||||||
|
|
||||||
|
use Friendica\Test\MockedTest;
|
||||||
|
|
||||||
|
class WebDavTest extends MockedTest
|
||||||
|
{
|
||||||
|
public function dataMultiStatus()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'nextcloud' => [
|
||||||
|
'xml' => <<<EOF
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:oc="http://owncloud.org/ns"
|
||||||
|
xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/files/admin/Friendica_test/97/18/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified>Mon, 30 Aug 2021 12:58:54 GMT</d:getlastmodified>
|
||||||
|
<d:resourcetype>
|
||||||
|
<d:collection/>
|
||||||
|
</d:resourcetype>
|
||||||
|
<d:quota-used-bytes>45017</d:quota-used-bytes>
|
||||||
|
<d:quota-available-bytes>59180834349</d:quota-available-bytes>
|
||||||
|
<d:getetag>"612cd60ec9fd5"</d:getetag>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
<d:response>
|
||||||
|
<d:href>
|
||||||
|
/remote.php/dav/files/admin/Friendica_test/97/18/4d9d36f614dc005756bdfb9abbf1d8d24aa9ae842e5d6b5e7eb1dafbe767
|
||||||
|
</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified>Mon, 30 Aug 2021 12:58:54 GMT</d:getlastmodified>
|
||||||
|
<d:getcontentlength>45017</d:getcontentlength>
|
||||||
|
<d:resourcetype/>
|
||||||
|
<d:getetag>"4f7a144092532141d0e6b925e50a896e"</d:getetag>
|
||||||
|
<d:getcontenttype>application/octet-stream
|
||||||
|
</d:getcontenttype>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:quota-used-bytes/>
|
||||||
|
<d:quota-available-bytes/>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 404 Not Found
|
||||||
|
</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
EOF,
|
||||||
|
'assertionCount' => 2,
|
||||||
|
],
|
||||||
|
'onlyDir' => [
|
||||||
|
'xml' => <<<EOF
|
||||||
|
<d:multistatus xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||||
|
<d:response>
|
||||||
|
<d:href>/remote.php/dav/files/admin/Friendica_test/34/cf/</d:href>
|
||||||
|
<d:propstat>
|
||||||
|
<d:prop>
|
||||||
|
<d:getlastmodified>Sun, 05 Sep 2021 17:56:05 GMT</d:getlastmodified>
|
||||||
|
<d:resourcetype>
|
||||||
|
<d:collection/>
|
||||||
|
</d:resourcetype>
|
||||||
|
<d:quota-used-bytes>0</d:quota-used-bytes>
|
||||||
|
<d:quota-available-bytes>59182800697</d:quota-available-bytes>
|
||||||
|
<d:getetag>"613504b55db4f"</d:getetag>
|
||||||
|
</d:prop>
|
||||||
|
<d:status>HTTP/1.1 200 OK</d:status>
|
||||||
|
</d:propstat>
|
||||||
|
</d:response>
|
||||||
|
</d:multistatus>
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
}
|
28
webdav_storage/webdav_storage.php
Normal file
28
webdav_storage/webdav_storage.php
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* Name: WebDAV Storage
|
||||||
|
* Description: Adds the possibility to use WebDAV as a selectable storage backend
|
||||||
|
* Version: 1.0
|
||||||
|
* Author: Philipp Holzer
|
||||||
|
*/
|
||||||
|
|
||||||
|
use Friendica\Addon\webdav_storage\src\WebDav;
|
||||||
|
use Friendica\App;
|
||||||
|
use Friendica\Core\Hook;
|
||||||
|
use Friendica\DI;
|
||||||
|
|
||||||
|
function webdav_storage_install($a)
|
||||||
|
{
|
||||||
|
Hook::register('storage_instance' , __FILE__, 'webdav_storage_instance');
|
||||||
|
DI::storageManager()->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());
|
||||||
|
}
|
Loading…
Reference in a new issue