diff options
Diffstat (limited to 'lib/private/Files/Storage/DAV.php')
-rw-r--r-- | lib/private/Files/Storage/DAV.php | 817 |
1 files changed, 817 insertions, 0 deletions
diff --git a/lib/private/Files/Storage/DAV.php b/lib/private/Files/Storage/DAV.php new file mode 100644 index 00000000000..8eebea1f3ba --- /dev/null +++ b/lib/private/Files/Storage/DAV.php @@ -0,0 +1,817 @@ +<?php +/** + * @author Bart Visscher <bartv@thisnet.nl> + * @author Björn Schießle <schiessle@owncloud.com> + * @author Carlos Cerrillo <ccerrillo@gmail.com> + * @author Felix Moeller <mail@felixmoeller.de> + * @author Jörn Friedrich Dreyer <jfd@butonic.de> + * @author Lukas Reschke <lukas@owncloud.com> + * @author Michael Gapczynski <GapczynskiM@gmail.com> + * @author Morris Jobke <hey@morrisjobke.de> + * @author Philipp Kapfer <philipp.kapfer@gmx.at> + * @author Robin Appelman <icewind@owncloud.com> + * @author Thomas Müller <thomas.mueller@tmit.eu> + * @author Vincent Petry <pvince81@owncloud.com> + * + * @copyright Copyright (c) 2016, ownCloud, Inc. + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see <http://www.gnu.org/licenses/> + * + */ + +namespace OC\Files\Storage; + +use Exception; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Message\ResponseInterface; +use OC\Files\Filesystem; +use OC\Files\Stream\Close; +use Icewind\Streams\IteratorDirectory; +use OC\MemCache\ArrayCache; +use OCP\AppFramework\Http; +use OCP\Constants; +use OCP\Files; +use OCP\Files\FileInfo; +use OCP\Files\StorageInvalidException; +use OCP\Files\StorageNotAvailableException; +use OCP\Util; +use Sabre\DAV\Client; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\Xml\Property\ResourceType; +use Sabre\HTTP\ClientException; +use Sabre\HTTP\ClientHttpException; + +/** + * Class DAV + * + * @package OC\Files\Storage + */ +class DAV extends Common { + /** @var string */ + protected $password; + /** @var string */ + protected $user; + /** @var string */ + protected $host; + /** @var bool */ + protected $secure; + /** @var string */ + protected $root; + /** @var string */ + protected $certPath; + /** @var bool */ + protected $ready; + /** @var Client */ + private $client; + /** @var ArrayCache */ + private $statCache; + /** @var array */ + private static $tempFiles = []; + /** @var \OCP\Http\Client\IClientService */ + private $httpClientService; + + /** + * @param array $params + * @throws \Exception + */ + public function __construct($params) { + $this->statCache = new ArrayCache(); + $this->httpClientService = \OC::$server->getHTTPClientService(); + if (isset($params['host']) && isset($params['user']) && isset($params['password'])) { + $host = $params['host']; + //remove leading http[s], will be generated in createBaseUri() + if (substr($host, 0, 8) == "https://") $host = substr($host, 8); + else if (substr($host, 0, 7) == "http://") $host = substr($host, 7); + $this->host = $host; + $this->user = $params['user']; + $this->password = $params['password']; + if (isset($params['secure'])) { + if (is_string($params['secure'])) { + $this->secure = ($params['secure'] === 'true'); + } else { + $this->secure = (bool)$params['secure']; + } + } else { + $this->secure = false; + } + if ($this->secure === true) { + // inject mock for testing + $certPath = \OC_User::getHome(\OC_User::getUser()) . '/files_external/rootcerts.crt'; + if (file_exists($certPath)) { + $this->certPath = $certPath; + } + } + $this->root = isset($params['root']) ? $params['root'] : '/'; + if (!$this->root || $this->root[0] != '/') { + $this->root = '/' . $this->root; + } + if (substr($this->root, -1, 1) != '/') { + $this->root .= '/'; + } + } else { + throw new \Exception('Invalid webdav storage configuration'); + } + } + + private function init() { + if ($this->ready) { + return; + } + $this->ready = true; + + $settings = array( + 'baseUri' => $this->createBaseUri(), + 'userName' => $this->user, + 'password' => $this->password, + ); + + $proxy = \OC::$server->getConfig()->getSystemValue('proxy', ''); + if($proxy !== '') { + $settings['proxy'] = $proxy; + } + + $this->client = new Client($settings); + $this->client->setThrowExceptions(true); + if ($this->secure === true && $this->certPath) { + $this->client->addCurlSetting(CURLOPT_CAINFO, $this->certPath); + } + } + + /** + * Clear the stat cache + */ + public function clearStatCache() { + $this->statCache->clear(); + } + + /** {@inheritdoc} */ + public function getId() { + return 'webdav::' . $this->user . '@' . $this->host . '/' . $this->root; + } + + /** {@inheritdoc} */ + public function createBaseUri() { + $baseUri = 'http'; + if ($this->secure) { + $baseUri .= 's'; + } + $baseUri .= '://' . $this->host . $this->root; + return $baseUri; + } + + /** {@inheritdoc} */ + public function mkdir($path) { + $this->init(); + $path = $this->cleanPath($path); + $result = $this->simpleResponse('MKCOL', $path, null, 201); + if ($result) { + $this->statCache->set($path, true); + } + return $result; + } + + /** {@inheritdoc} */ + public function rmdir($path) { + $this->init(); + $path = $this->cleanPath($path); + // FIXME: some WebDAV impl return 403 when trying to DELETE + // a non-empty folder + $result = $this->simpleResponse('DELETE', $path . '/', null, 204); + $this->statCache->clear($path . '/'); + $this->statCache->remove($path); + return $result; + } + + /** {@inheritdoc} */ + public function opendir($path) { + $this->init(); + $path = $this->cleanPath($path); + try { + $response = $this->client->propfind( + $this->encodePath($path), + array(), + 1 + ); + $id = md5('webdav' . $this->root . $path); + $content = array(); + $files = array_keys($response); + array_shift($files); //the first entry is the current directory + + if (!$this->statCache->hasKey($path)) { + $this->statCache->set($path, true); + } + foreach ($files as $file) { + $file = urldecode($file); + // do not store the real entry, we might not have all properties + if (!$this->statCache->hasKey($path)) { + $this->statCache->set($file, true); + } + $file = basename($file); + $content[] = $file; + } + return IteratorDirectory::wrap($content); + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 404) { + $this->statCache->clear($path . '/'); + $this->statCache->set($path, false); + return false; + } + $this->convertException($e, $path); + } catch (\Exception $e) { + $this->convertException($e, $path); + } + return false; + } + + /** + * Propfind call with cache handling. + * + * First checks if information is cached. + * If not, request it from the server then store to cache. + * + * @param string $path path to propfind + * + * @return array propfind response + * + * @throws NotFound + */ + protected function propfind($path) { + $path = $this->cleanPath($path); + $cachedResponse = $this->statCache->get($path); + if ($cachedResponse === false) { + // we know it didn't exist + throw new NotFound(); + } + // we either don't know it, or we know it exists but need more details + if (is_null($cachedResponse) || $cachedResponse === true) { + $this->init(); + try { + $response = $this->client->propfind( + $this->encodePath($path), + array( + '{DAV:}getlastmodified', + '{DAV:}getcontentlength', + '{DAV:}getcontenttype', + '{http://owncloud.org/ns}permissions', + '{http://open-collaboration-services.org/ns}share-permissions', + '{DAV:}resourcetype', + '{DAV:}getetag', + ) + ); + $this->statCache->set($path, $response); + } catch (NotFound $e) { + // remember that this path did not exist + $this->statCache->clear($path . '/'); + $this->statCache->set($path, false); + throw $e; + } + } else { + $response = $cachedResponse; + } + return $response; + } + + /** {@inheritdoc} */ + public function filetype($path) { + try { + $response = $this->propfind($path); + $responseType = array(); + if (isset($response["{DAV:}resourcetype"])) { + /** @var ResourceType[] $response */ + $responseType = $response["{DAV:}resourcetype"]->getValue(); + } + return (count($responseType) > 0 and $responseType[0] == "{DAV:}collection") ? 'dir' : 'file'; + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 404) { + return false; + } + $this->convertException($e, $path); + } catch (\Exception $e) { + $this->convertException($e, $path); + } + return false; + } + + /** {@inheritdoc} */ + public function file_exists($path) { + try { + $path = $this->cleanPath($path); + $cachedState = $this->statCache->get($path); + if ($cachedState === false) { + // we know the file doesn't exist + return false; + } else if (!is_null($cachedState)) { + return true; + } + // need to get from server + $this->propfind($path); + return true; //no 404 exception + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 404) { + return false; + } + $this->convertException($e, $path); + } catch (\Exception $e) { + $this->convertException($e, $path); + } + return false; + } + + /** {@inheritdoc} */ + public function unlink($path) { + $this->init(); + $path = $this->cleanPath($path); + $result = $this->simpleResponse('DELETE', $path, null, 204); + $this->statCache->clear($path . '/'); + $this->statCache->remove($path); + return $result; + } + + /** {@inheritdoc} */ + public function fopen($path, $mode) { + $this->init(); + $path = $this->cleanPath($path); + switch ($mode) { + case 'r': + case 'rb': + try { + $response = $this->httpClientService + ->newClient() + ->get($this->createBaseUri() . $this->encodePath($path), [ + 'auth' => [$this->user, $this->password], + 'stream' => true + ]); + } catch (RequestException $e) { + if ($e->getResponse() instanceof ResponseInterface + && $e->getResponse()->getStatusCode() === 404) { + return false; + } else { + throw $e; + } + } + + if ($response->getStatusCode() !== Http::STATUS_OK) { + if ($response->getStatusCode() === Http::STATUS_LOCKED) { + throw new \OCP\Lock\LockedException($path); + } else { + Util::writeLog("webdav client", 'Guzzle get returned status code ' . $response->getStatusCode(), Util::ERROR); + } + } + + return $response->getBody(); + case 'w': + case 'wb': + case 'a': + case 'ab': + case 'r+': + case 'w+': + case 'wb+': + case 'a+': + case 'x': + case 'x+': + case 'c': + case 'c+': + //emulate these + $tempManager = \OC::$server->getTempManager(); + if (strrpos($path, '.') !== false) { + $ext = substr($path, strrpos($path, '.')); + } else { + $ext = ''; + } + if ($this->file_exists($path)) { + if (!$this->isUpdatable($path)) { + return false; + } + if ($mode === 'w' or $mode === 'w+') { + $tmpFile = $tempManager->getTemporaryFile($ext); + } else { + $tmpFile = $this->getCachedFile($path); + } + } else { + if (!$this->isCreatable(dirname($path))) { + return false; + } + $tmpFile = $tempManager->getTemporaryFile($ext); + } + Close::registerCallback($tmpFile, array($this, 'writeBack')); + self::$tempFiles[$tmpFile] = $path; + return fopen('close://' . $tmpFile, $mode); + } + } + + /** + * @param string $tmpFile + */ + public function writeBack($tmpFile) { + if (isset(self::$tempFiles[$tmpFile])) { + $this->uploadFile($tmpFile, self::$tempFiles[$tmpFile]); + unlink($tmpFile); + } + } + + /** {@inheritdoc} */ + public function free_space($path) { + $this->init(); + $path = $this->cleanPath($path); + try { + // TODO: cacheable ? + $response = $this->client->propfind($this->encodePath($path), array('{DAV:}quota-available-bytes')); + if (isset($response['{DAV:}quota-available-bytes'])) { + return (int)$response['{DAV:}quota-available-bytes']; + } else { + return FileInfo::SPACE_UNKNOWN; + } + } catch (\Exception $e) { + return FileInfo::SPACE_UNKNOWN; + } + } + + /** {@inheritdoc} */ + public function touch($path, $mtime = null) { + $this->init(); + if (is_null($mtime)) { + $mtime = time(); + } + $path = $this->cleanPath($path); + + // if file exists, update the mtime, else create a new empty file + if ($this->file_exists($path)) { + try { + $this->statCache->remove($path); + $this->client->proppatch($this->encodePath($path), array('{DAV:}lastmodified' => $mtime)); + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 501) { + return false; + } + $this->convertException($e, $path); + return false; + } catch (\Exception $e) { + $this->convertException($e, $path); + return false; + } + } else { + $this->file_put_contents($path, ''); + } + return true; + } + + /** + * @param string $path + * @param string $data + * @return int + */ + public function file_put_contents($path, $data) { + $path = $this->cleanPath($path); + $result = parent::file_put_contents($path, $data); + $this->statCache->remove($path); + return $result; + } + + /** + * @param string $path + * @param string $target + */ + protected function uploadFile($path, $target) { + $this->init(); + + // invalidate + $target = $this->cleanPath($target); + $this->statCache->remove($target); + $source = fopen($path, 'r'); + + $this->httpClientService + ->newClient() + ->put($this->createBaseUri() . $this->encodePath($target), [ + 'body' => $source, + 'auth' => [$this->user, $this->password] + ]); + + $this->removeCachedFile($target); + } + + /** {@inheritdoc} */ + public function rename($path1, $path2) { + $this->init(); + $path1 = $this->cleanPath($path1); + $path2 = $this->cleanPath($path2); + try { + $this->client->request( + 'MOVE', + $this->encodePath($path1), + null, + array( + 'Destination' => $this->createBaseUri() . $this->encodePath($path2) + ) + ); + $this->statCache->clear($path1 . '/'); + $this->statCache->clear($path2 . '/'); + $this->statCache->set($path1, false); + $this->statCache->set($path2, true); + $this->removeCachedFile($path1); + $this->removeCachedFile($path2); + return true; + } catch (\Exception $e) { + $this->convertException($e); + } + return false; + } + + /** {@inheritdoc} */ + public function copy($path1, $path2) { + $this->init(); + $path1 = $this->encodePath($this->cleanPath($path1)); + $path2 = $this->createBaseUri() . $this->encodePath($this->cleanPath($path2)); + try { + $this->client->request('COPY', $path1, null, array('Destination' => $path2)); + $this->statCache->clear($path2 . '/'); + $this->statCache->set($path2, true); + $this->removeCachedFile($path2); + return true; + } catch (\Exception $e) { + $this->convertException($e); + } + return false; + } + + /** {@inheritdoc} */ + public function stat($path) { + try { + $response = $this->propfind($path); + return array( + 'mtime' => strtotime($response['{DAV:}getlastmodified']), + 'size' => (int)isset($response['{DAV:}getcontentlength']) ? $response['{DAV:}getcontentlength'] : 0, + ); + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 404) { + return array(); + } + $this->convertException($e, $path); + } catch (\Exception $e) { + $this->convertException($e, $path); + } + return array(); + } + + /** {@inheritdoc} */ + public function getMimeType($path) { + try { + $response = $this->propfind($path); + $responseType = array(); + if (isset($response["{DAV:}resourcetype"])) { + /** @var ResourceType[] $response */ + $responseType = $response["{DAV:}resourcetype"]->getValue(); + } + $type = (count($responseType) > 0 and $responseType[0] == "{DAV:}collection") ? 'dir' : 'file'; + if ($type == 'dir') { + return 'httpd/unix-directory'; + } elseif (isset($response['{DAV:}getcontenttype'])) { + return $response['{DAV:}getcontenttype']; + } else { + return false; + } + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 404) { + return false; + } + $this->convertException($e, $path); + } catch (\Exception $e) { + $this->convertException($e, $path); + } + return false; + } + + /** + * @param string $path + * @return string + */ + public function cleanPath($path) { + if ($path === '') { + return $path; + } + $path = Filesystem::normalizePath($path); + // remove leading slash + return substr($path, 1); + } + + /** + * URL encodes the given path but keeps the slashes + * + * @param string $path to encode + * @return string encoded path + */ + private function encodePath($path) { + // slashes need to stay + return str_replace('%2F', '/', rawurlencode($path)); + } + + /** + * @param string $method + * @param string $path + * @param string|resource|null $body + * @param int $expected + * @return bool + * @throws StorageInvalidException + * @throws StorageNotAvailableException + */ + private function simpleResponse($method, $path, $body, $expected) { + $path = $this->cleanPath($path); + try { + $response = $this->client->request($method, $this->encodePath($path), $body); + return $response['statusCode'] == $expected; + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 404 && $method === 'DELETE') { + $this->statCache->clear($path . '/'); + $this->statCache->set($path, false); + return false; + } + + $this->convertException($e, $path); + } catch (\Exception $e) { + $this->convertException($e, $path); + } + return false; + } + + /** + * check if curl is installed + */ + public static function checkDependencies() { + return true; + } + + /** {@inheritdoc} */ + public function isUpdatable($path) { + return (bool)($this->getPermissions($path) & Constants::PERMISSION_UPDATE); + } + + /** {@inheritdoc} */ + public function isCreatable($path) { + return (bool)($this->getPermissions($path) & Constants::PERMISSION_CREATE); + } + + /** {@inheritdoc} */ + public function isSharable($path) { + return (bool)($this->getPermissions($path) & Constants::PERMISSION_SHARE); + } + + /** {@inheritdoc} */ + public function isDeletable($path) { + return (bool)($this->getPermissions($path) & Constants::PERMISSION_DELETE); + } + + /** {@inheritdoc} */ + public function getPermissions($path) { + $this->init(); + $path = $this->cleanPath($path); + $response = $this->propfind($path); + if (isset($response['{http://owncloud.org/ns}permissions'])) { + return $this->parsePermissions($response['{http://owncloud.org/ns}permissions']); + } else if ($this->is_dir($path)) { + return Constants::PERMISSION_ALL; + } else if ($this->file_exists($path)) { + return Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE; + } else { + return 0; + } + } + + /** {@inheritdoc} */ + public function getETag($path) { + $this->init(); + $path = $this->cleanPath($path); + $response = $this->propfind($path); + if (isset($response['{DAV:}getetag'])) { + return trim($response['{DAV:}getetag'], '"'); + } + return parent::getEtag($path); + } + + /** + * @param string $permissionsString + * @return int + */ + protected function parsePermissions($permissionsString) { + $permissions = Constants::PERMISSION_READ; + if (strpos($permissionsString, 'R') !== false) { + $permissions |= Constants::PERMISSION_SHARE; + } + if (strpos($permissionsString, 'D') !== false) { + $permissions |= Constants::PERMISSION_DELETE; + } + if (strpos($permissionsString, 'W') !== false) { + $permissions |= Constants::PERMISSION_UPDATE; + } + if (strpos($permissionsString, 'CK') !== false) { + $permissions |= Constants::PERMISSION_CREATE; + $permissions |= Constants::PERMISSION_UPDATE; + } + return $permissions; + } + + /** + * check if a file or folder has been updated since $time + * + * @param string $path + * @param int $time + * @throws \OCP\Files\StorageNotAvailableException + * @return bool + */ + public function hasUpdated($path, $time) { + $this->init(); + $path = $this->cleanPath($path); + try { + // force refresh for $path + $this->statCache->remove($path); + $response = $this->propfind($path); + if (isset($response['{DAV:}getetag'])) { + $cachedData = $this->getCache()->get($path); + $etag = null; + if (isset($response['{DAV:}getetag'])) { + $etag = trim($response['{DAV:}getetag'], '"'); + } + if (!empty($etag) && $cachedData['etag'] !== $etag) { + return true; + } else if (isset($response['{http://open-collaboration-services.org/ns}share-permissions'])) { + $sharePermissions = (int)$response['{http://open-collaboration-services.org/ns}share-permissions']; + return $sharePermissions !== $cachedData['permissions']; + } else if (isset($response['{http://owncloud.org/ns}permissions'])) { + $permissions = $this->parsePermissions($response['{http://owncloud.org/ns}permissions']); + return $permissions !== $cachedData['permissions']; + } else { + return false; + } + } else { + $remoteMtime = strtotime($response['{DAV:}getlastmodified']); + return $remoteMtime > $time; + } + } catch (ClientHttpException $e) { + if ($e->getHttpStatus() === 404 || $e->getHttpStatus() === 405) { + if ($path === '') { + // if root is gone it means the storage is not available + throw new StorageNotAvailableException(get_class($e) . ': ' . $e->getMessage()); + } + return false; + } + $this->convertException($e, $path); + return false; + } catch (\Exception $e) { + $this->convertException($e, $path); + return false; + } + } + + /** + * Interpret the given exception and decide whether it is due to an + * unavailable storage, invalid storage or other. + * This will either throw StorageInvalidException, StorageNotAvailableException + * or do nothing. + * + * @param Exception $e sabre exception + * @param string $path optional path from the operation + * + * @throws StorageInvalidException if the storage is invalid, for example + * when the authentication expired or is invalid + * @throws StorageNotAvailableException if the storage is not available, + * which might be temporary + */ + private function convertException(Exception $e, $path = '') { + Util::writeLog('files_external', $e->getMessage(), Util::ERROR); + if ($e instanceof ClientHttpException) { + if ($e->getHttpStatus() === 423) { + throw new \OCP\Lock\LockedException($path); + } + if ($e->getHttpStatus() === 401) { + // either password was changed or was invalid all along + throw new StorageInvalidException(get_class($e) . ': ' . $e->getMessage()); + } else if ($e->getHttpStatus() === 405) { + // ignore exception for MethodNotAllowed, false will be returned + return; + } + throw new StorageNotAvailableException(get_class($e) . ': ' . $e->getMessage()); + } else if ($e instanceof ClientException) { + // connection timeout or refused, server could be temporarily down + throw new StorageNotAvailableException(get_class($e) . ': ' . $e->getMessage()); + } else if ($e instanceof \InvalidArgumentException) { + // parse error because the server returned HTML instead of XML, + // possibly temporarily down + throw new StorageNotAvailableException(get_class($e) . ': ' . $e->getMessage()); + } else if (($e instanceof StorageNotAvailableException) || ($e instanceof StorageInvalidException)) { + // rethrow + throw $e; + } + + // TODO: only log for now, but in the future need to wrap/rethrow exception + } +} + |