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; } }