DAV.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
  5. * SPDX-License-Identifier: AGPL-3.0-only
  6. */
  7. namespace OC\Files\Storage;
  8. use Exception;
  9. use Icewind\Streams\CallbackWrapper;
  10. use Icewind\Streams\IteratorDirectory;
  11. use OC\Files\Filesystem;
  12. use OC\MemCache\ArrayCache;
  13. use OCP\AppFramework\Http;
  14. use OCP\Constants;
  15. use OCP\Diagnostics\IEventLogger;
  16. use OCP\Files\FileInfo;
  17. use OCP\Files\ForbiddenException;
  18. use OCP\Files\IMimeTypeDetector;
  19. use OCP\Files\StorageInvalidException;
  20. use OCP\Files\StorageNotAvailableException;
  21. use OCP\Http\Client\IClient;
  22. use OCP\Http\Client\IClientService;
  23. use OCP\ICertificateManager;
  24. use OCP\IConfig;
  25. use OCP\Server;
  26. use OCP\Util;
  27. use Psr\Http\Message\ResponseInterface;
  28. use Psr\Log\LoggerInterface;
  29. use Sabre\DAV\Client;
  30. use Sabre\DAV\Xml\Property\ResourceType;
  31. use Sabre\HTTP\ClientException;
  32. use Sabre\HTTP\ClientHttpException;
  33. use Sabre\HTTP\RequestInterface;
  34. /**
  35. * Class DAV
  36. *
  37. * @package OC\Files\Storage
  38. */
  39. class DAV extends Common {
  40. /** @var string */
  41. protected $password;
  42. /** @var string */
  43. protected $user;
  44. /** @var string|null */
  45. protected $authType;
  46. /** @var string */
  47. protected $host;
  48. /** @var bool */
  49. protected $secure;
  50. /** @var string */
  51. protected $root;
  52. /** @var string */
  53. protected $certPath;
  54. /** @var bool */
  55. protected $ready;
  56. /** @var Client */
  57. protected $client;
  58. /** @var ArrayCache */
  59. protected $statCache;
  60. /** @var IClientService */
  61. protected $httpClientService;
  62. /** @var ICertificateManager */
  63. protected $certManager;
  64. protected LoggerInterface $logger;
  65. protected IEventLogger $eventLogger;
  66. protected IMimeTypeDetector $mimeTypeDetector;
  67. /** @var int */
  68. private $timeout;
  69. protected const PROPFIND_PROPS = [
  70. '{DAV:}getlastmodified',
  71. '{DAV:}getcontentlength',
  72. '{DAV:}getcontenttype',
  73. '{http://owncloud.org/ns}permissions',
  74. '{http://open-collaboration-services.org/ns}share-permissions',
  75. '{DAV:}resourcetype',
  76. '{DAV:}getetag',
  77. '{DAV:}quota-available-bytes',
  78. ];
  79. /**
  80. * @param array $parameters
  81. * @throws \Exception
  82. */
  83. public function __construct(array $parameters) {
  84. $this->statCache = new ArrayCache();
  85. $this->httpClientService = Server::get(IClientService::class);
  86. if (isset($parameters['host']) && isset($parameters['user']) && isset($parameters['password'])) {
  87. $host = $parameters['host'];
  88. //remove leading http[s], will be generated in createBaseUri()
  89. if (str_starts_with($host, 'https://')) {
  90. $host = substr($host, 8);
  91. } elseif (str_starts_with($host, 'http://')) {
  92. $host = substr($host, 7);
  93. }
  94. $this->host = $host;
  95. $this->user = $parameters['user'];
  96. $this->password = $parameters['password'];
  97. if (isset($parameters['authType'])) {
  98. $this->authType = $parameters['authType'];
  99. }
  100. if (isset($parameters['secure'])) {
  101. if (is_string($parameters['secure'])) {
  102. $this->secure = ($parameters['secure'] === 'true');
  103. } else {
  104. $this->secure = (bool)$parameters['secure'];
  105. }
  106. } else {
  107. $this->secure = false;
  108. }
  109. if ($this->secure === true) {
  110. // inject mock for testing
  111. $this->certManager = \OC::$server->getCertificateManager();
  112. }
  113. $this->root = $parameters['root'] ?? '/';
  114. $this->root = '/' . ltrim($this->root, '/');
  115. $this->root = rtrim($this->root, '/') . '/';
  116. } else {
  117. throw new \Exception('Invalid webdav storage configuration');
  118. }
  119. $this->logger = Server::get(LoggerInterface::class);
  120. $this->eventLogger = Server::get(IEventLogger::class);
  121. // This timeout value will be used for the download and upload of files
  122. $this->timeout = Server::get(IConfig::class)->getSystemValueInt('davstorage.request_timeout', IClient::DEFAULT_REQUEST_TIMEOUT);
  123. $this->mimeTypeDetector = \OC::$server->getMimeTypeDetector();
  124. }
  125. protected function init(): void {
  126. if ($this->ready) {
  127. return;
  128. }
  129. $this->ready = true;
  130. $settings = [
  131. 'baseUri' => $this->createBaseUri(),
  132. 'userName' => $this->user,
  133. 'password' => $this->password,
  134. ];
  135. if ($this->authType !== null) {
  136. $settings['authType'] = $this->authType;
  137. }
  138. $proxy = Server::get(IConfig::class)->getSystemValueString('proxy', '');
  139. if ($proxy !== '') {
  140. $settings['proxy'] = $proxy;
  141. }
  142. $this->client = new Client($settings);
  143. $this->client->setThrowExceptions(true);
  144. if ($this->secure === true) {
  145. $certPath = $this->certManager->getAbsoluteBundlePath();
  146. if (file_exists($certPath)) {
  147. $this->certPath = $certPath;
  148. }
  149. if ($this->certPath) {
  150. $this->client->addCurlSetting(CURLOPT_CAINFO, $this->certPath);
  151. }
  152. }
  153. $lastRequestStart = 0;
  154. $this->client->on('beforeRequest', function (RequestInterface $request) use (&$lastRequestStart) {
  155. $this->logger->debug('sending dav ' . $request->getMethod() . ' request to external storage: ' . $request->getAbsoluteUrl(), ['app' => 'dav']);
  156. $lastRequestStart = microtime(true);
  157. $this->eventLogger->start('fs:storage:dav:request', 'Sending dav request to external storage');
  158. });
  159. $this->client->on('afterRequest', function (RequestInterface $request) use (&$lastRequestStart) {
  160. $elapsed = microtime(true) - $lastRequestStart;
  161. $this->logger->debug('dav ' . $request->getMethod() . ' request to external storage: ' . $request->getAbsoluteUrl() . ' took ' . round($elapsed * 1000, 1) . 'ms', ['app' => 'dav']);
  162. $this->eventLogger->end('fs:storage:dav:request');
  163. });
  164. }
  165. /**
  166. * Clear the stat cache
  167. */
  168. public function clearStatCache(): void {
  169. $this->statCache->clear();
  170. }
  171. public function getId(): string {
  172. return 'webdav::' . $this->user . '@' . $this->host . '/' . $this->root;
  173. }
  174. public function createBaseUri(): string {
  175. $baseUri = 'http';
  176. if ($this->secure) {
  177. $baseUri .= 's';
  178. }
  179. $baseUri .= '://' . $this->host . $this->root;
  180. return $baseUri;
  181. }
  182. public function mkdir(string $path): bool {
  183. $this->init();
  184. $path = $this->cleanPath($path);
  185. $result = $this->simpleResponse('MKCOL', $path, null, 201);
  186. if ($result) {
  187. $this->statCache->set($path, true);
  188. }
  189. return $result;
  190. }
  191. public function rmdir(string $path): bool {
  192. $this->init();
  193. $path = $this->cleanPath($path);
  194. // FIXME: some WebDAV impl return 403 when trying to DELETE
  195. // a non-empty folder
  196. $result = $this->simpleResponse('DELETE', $path . '/', null, 204);
  197. $this->statCache->clear($path . '/');
  198. $this->statCache->remove($path);
  199. return $result;
  200. }
  201. public function opendir(string $path) {
  202. $this->init();
  203. $path = $this->cleanPath($path);
  204. try {
  205. $content = $this->getDirectoryContent($path);
  206. $files = [];
  207. foreach ($content as $child) {
  208. $files[] = $child['name'];
  209. }
  210. return IteratorDirectory::wrap($files);
  211. } catch (\Exception $e) {
  212. $this->convertException($e, $path);
  213. }
  214. return false;
  215. }
  216. /**
  217. * Propfind call with cache handling.
  218. *
  219. * First checks if information is cached.
  220. * If not, request it from the server then store to cache.
  221. *
  222. * @param string $path path to propfind
  223. *
  224. * @return array|false propfind response or false if the entry was not found
  225. *
  226. * @throws ClientHttpException
  227. */
  228. protected function propfind(string $path): array|false {
  229. $path = $this->cleanPath($path);
  230. $cachedResponse = $this->statCache->get($path);
  231. // we either don't know it, or we know it exists but need more details
  232. if (is_null($cachedResponse) || $cachedResponse === true) {
  233. $this->init();
  234. $response = false;
  235. try {
  236. $response = $this->client->propFind(
  237. $this->encodePath($path),
  238. self::PROPFIND_PROPS
  239. );
  240. $this->statCache->set($path, $response);
  241. } catch (ClientHttpException $e) {
  242. if ($e->getHttpStatus() === 404 || $e->getHttpStatus() === 405) {
  243. $this->statCache->clear($path . '/');
  244. $this->statCache->set($path, false);
  245. } else {
  246. $this->convertException($e, $path);
  247. }
  248. } catch (\Exception $e) {
  249. $this->convertException($e, $path);
  250. }
  251. } else {
  252. $response = $cachedResponse;
  253. }
  254. return $response;
  255. }
  256. public function filetype(string $path): string|false {
  257. try {
  258. $response = $this->propfind($path);
  259. if ($response === false) {
  260. return false;
  261. }
  262. $responseType = [];
  263. if (isset($response['{DAV:}resourcetype'])) {
  264. /** @var ResourceType[] $response */
  265. $responseType = $response['{DAV:}resourcetype']->getValue();
  266. }
  267. return (count($responseType) > 0 && $responseType[0] == '{DAV:}collection') ? 'dir' : 'file';
  268. } catch (\Exception $e) {
  269. $this->convertException($e, $path);
  270. }
  271. return false;
  272. }
  273. public function file_exists(string $path): bool {
  274. try {
  275. $path = $this->cleanPath($path);
  276. $cachedState = $this->statCache->get($path);
  277. if ($cachedState === false) {
  278. // we know the file doesn't exist
  279. return false;
  280. } elseif (!is_null($cachedState)) {
  281. return true;
  282. }
  283. // need to get from server
  284. return ($this->propfind($path) !== false);
  285. } catch (\Exception $e) {
  286. $this->convertException($e, $path);
  287. }
  288. return false;
  289. }
  290. public function unlink(string $path): bool {
  291. $this->init();
  292. $path = $this->cleanPath($path);
  293. $result = $this->simpleResponse('DELETE', $path, null, 204);
  294. $this->statCache->clear($path . '/');
  295. $this->statCache->remove($path);
  296. return $result;
  297. }
  298. public function fopen(string $path, string $mode) {
  299. $this->init();
  300. $path = $this->cleanPath($path);
  301. switch ($mode) {
  302. case 'r':
  303. case 'rb':
  304. try {
  305. $response = $this->httpClientService
  306. ->newClient()
  307. ->get($this->createBaseUri() . $this->encodePath($path), [
  308. 'auth' => [$this->user, $this->password],
  309. 'stream' => true,
  310. // set download timeout for users with slow connections or large files
  311. 'timeout' => $this->timeout
  312. ]);
  313. } catch (\GuzzleHttp\Exception\ClientException $e) {
  314. if ($e->getResponse() instanceof ResponseInterface
  315. && $e->getResponse()->getStatusCode() === 404) {
  316. return false;
  317. } else {
  318. throw $e;
  319. }
  320. }
  321. if ($response->getStatusCode() !== Http::STATUS_OK) {
  322. if ($response->getStatusCode() === Http::STATUS_LOCKED) {
  323. throw new \OCP\Lock\LockedException($path);
  324. } else {
  325. $this->logger->error('Guzzle get returned status code ' . $response->getStatusCode(), ['app' => 'webdav client']);
  326. }
  327. }
  328. return $response->getBody();
  329. case 'w':
  330. case 'wb':
  331. case 'a':
  332. case 'ab':
  333. case 'r+':
  334. case 'w+':
  335. case 'wb+':
  336. case 'a+':
  337. case 'x':
  338. case 'x+':
  339. case 'c':
  340. case 'c+':
  341. //emulate these
  342. $tempManager = \OC::$server->getTempManager();
  343. if (strrpos($path, '.') !== false) {
  344. $ext = substr($path, strrpos($path, '.'));
  345. } else {
  346. $ext = '';
  347. }
  348. if ($this->file_exists($path)) {
  349. if (!$this->isUpdatable($path)) {
  350. return false;
  351. }
  352. if ($mode === 'w' || $mode === 'w+') {
  353. $tmpFile = $tempManager->getTemporaryFile($ext);
  354. } else {
  355. $tmpFile = $this->getCachedFile($path);
  356. }
  357. } else {
  358. if (!$this->isCreatable(dirname($path))) {
  359. return false;
  360. }
  361. $tmpFile = $tempManager->getTemporaryFile($ext);
  362. }
  363. $handle = fopen($tmpFile, $mode);
  364. return CallbackWrapper::wrap($handle, null, null, function () use ($path, $tmpFile) {
  365. $this->writeBack($tmpFile, $path);
  366. });
  367. }
  368. }
  369. public function writeBack(string $tmpFile, string $path): void {
  370. $this->uploadFile($tmpFile, $path);
  371. unlink($tmpFile);
  372. }
  373. public function free_space(string $path): int|float|false {
  374. $this->init();
  375. $path = $this->cleanPath($path);
  376. try {
  377. $response = $this->propfind($path);
  378. if ($response === false) {
  379. return FileInfo::SPACE_UNKNOWN;
  380. }
  381. if (isset($response['{DAV:}quota-available-bytes'])) {
  382. return Util::numericToNumber($response['{DAV:}quota-available-bytes']);
  383. } else {
  384. return FileInfo::SPACE_UNKNOWN;
  385. }
  386. } catch (\Exception $e) {
  387. return FileInfo::SPACE_UNKNOWN;
  388. }
  389. }
  390. public function touch(string $path, ?int $mtime = null): bool {
  391. $this->init();
  392. if (is_null($mtime)) {
  393. $mtime = time();
  394. }
  395. $path = $this->cleanPath($path);
  396. // if file exists, update the mtime, else create a new empty file
  397. if ($this->file_exists($path)) {
  398. try {
  399. $this->statCache->remove($path);
  400. $this->client->proppatch($this->encodePath($path), ['{DAV:}lastmodified' => $mtime]);
  401. // non-owncloud clients might not have accepted the property, need to recheck it
  402. $response = $this->client->propfind($this->encodePath($path), ['{DAV:}getlastmodified'], 0);
  403. if (isset($response['{DAV:}getlastmodified'])) {
  404. $remoteMtime = strtotime($response['{DAV:}getlastmodified']);
  405. if ($remoteMtime !== $mtime) {
  406. // server has not accepted the mtime
  407. return false;
  408. }
  409. }
  410. } catch (ClientHttpException $e) {
  411. if ($e->getHttpStatus() === 501) {
  412. return false;
  413. }
  414. $this->convertException($e, $path);
  415. return false;
  416. } catch (\Exception $e) {
  417. $this->convertException($e, $path);
  418. return false;
  419. }
  420. } else {
  421. $this->file_put_contents($path, '');
  422. }
  423. return true;
  424. }
  425. public function file_put_contents(string $path, mixed $data): int|float|false {
  426. $path = $this->cleanPath($path);
  427. $result = parent::file_put_contents($path, $data);
  428. $this->statCache->remove($path);
  429. return $result;
  430. }
  431. protected function uploadFile(string $path, string $target): void {
  432. $this->init();
  433. // invalidate
  434. $target = $this->cleanPath($target);
  435. $this->statCache->remove($target);
  436. $source = fopen($path, 'r');
  437. $this->httpClientService
  438. ->newClient()
  439. ->put($this->createBaseUri() . $this->encodePath($target), [
  440. 'body' => $source,
  441. 'auth' => [$this->user, $this->password],
  442. // set upload timeout for users with slow connections or large files
  443. 'timeout' => $this->timeout
  444. ]);
  445. $this->removeCachedFile($target);
  446. }
  447. public function rename(string $source, string $target): bool {
  448. $this->init();
  449. $source = $this->cleanPath($source);
  450. $target = $this->cleanPath($target);
  451. try {
  452. // overwrite directory ?
  453. if ($this->is_dir($target)) {
  454. // needs trailing slash in destination
  455. $target = rtrim($target, '/') . '/';
  456. }
  457. $this->client->request(
  458. 'MOVE',
  459. $this->encodePath($source),
  460. null,
  461. [
  462. 'Destination' => $this->createBaseUri() . $this->encodePath($target),
  463. ]
  464. );
  465. $this->statCache->clear($source . '/');
  466. $this->statCache->clear($target . '/');
  467. $this->statCache->set($source, false);
  468. $this->statCache->set($target, true);
  469. $this->removeCachedFile($source);
  470. $this->removeCachedFile($target);
  471. return true;
  472. } catch (\Exception $e) {
  473. $this->convertException($e);
  474. }
  475. return false;
  476. }
  477. public function copy(string $source, string $target): bool {
  478. $this->init();
  479. $source = $this->cleanPath($source);
  480. $target = $this->cleanPath($target);
  481. try {
  482. // overwrite directory ?
  483. if ($this->is_dir($target)) {
  484. // needs trailing slash in destination
  485. $target = rtrim($target, '/') . '/';
  486. }
  487. $this->client->request(
  488. 'COPY',
  489. $this->encodePath($source),
  490. null,
  491. [
  492. 'Destination' => $this->createBaseUri() . $this->encodePath($target),
  493. ]
  494. );
  495. $this->statCache->clear($target . '/');
  496. $this->statCache->set($target, true);
  497. $this->removeCachedFile($target);
  498. return true;
  499. } catch (\Exception $e) {
  500. $this->convertException($e);
  501. }
  502. return false;
  503. }
  504. public function getMetaData(string $path): ?array {
  505. if (Filesystem::isFileBlacklisted($path)) {
  506. throw new ForbiddenException('Invalid path: ' . $path, false);
  507. }
  508. $response = $this->propfind($path);
  509. if (!$response) {
  510. return null;
  511. } else {
  512. return $this->getMetaFromPropfind($path, $response);
  513. }
  514. }
  515. private function getMetaFromPropfind(string $path, array $response): array {
  516. if (isset($response['{DAV:}getetag'])) {
  517. $etag = trim($response['{DAV:}getetag'], '"');
  518. if (strlen($etag) > 40) {
  519. $etag = md5($etag);
  520. }
  521. } else {
  522. $etag = parent::getETag($path);
  523. }
  524. $responseType = [];
  525. if (isset($response['{DAV:}resourcetype'])) {
  526. /** @var ResourceType[] $response */
  527. $responseType = $response['{DAV:}resourcetype']->getValue();
  528. }
  529. $type = (count($responseType) > 0 && $responseType[0] == '{DAV:}collection') ? 'dir' : 'file';
  530. if ($type === 'dir') {
  531. $mimeType = 'httpd/unix-directory';
  532. } elseif (isset($response['{DAV:}getcontenttype'])) {
  533. $mimeType = $response['{DAV:}getcontenttype'];
  534. } else {
  535. $mimeType = $this->mimeTypeDetector->detectPath($path);
  536. }
  537. if (isset($response['{http://owncloud.org/ns}permissions'])) {
  538. $permissions = $this->parsePermissions($response['{http://owncloud.org/ns}permissions']);
  539. } elseif ($type === 'dir') {
  540. $permissions = Constants::PERMISSION_ALL;
  541. } else {
  542. $permissions = Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE;
  543. }
  544. $mtime = isset($response['{DAV:}getlastmodified']) ? strtotime($response['{DAV:}getlastmodified']) : null;
  545. if ($type === 'dir') {
  546. $size = -1;
  547. } else {
  548. $size = Util::numericToNumber($response['{DAV:}getcontentlength'] ?? 0);
  549. }
  550. return [
  551. 'name' => basename($path),
  552. 'mtime' => $mtime,
  553. 'storage_mtime' => $mtime,
  554. 'size' => $size,
  555. 'permissions' => $permissions,
  556. 'etag' => $etag,
  557. 'mimetype' => $mimeType,
  558. ];
  559. }
  560. public function stat(string $path): array|false {
  561. $meta = $this->getMetaData($path);
  562. return $meta ?: false;
  563. }
  564. public function getMimeType(string $path): string|false {
  565. $meta = $this->getMetaData($path);
  566. return $meta ? $meta['mimetype'] : false;
  567. }
  568. public function cleanPath(string $path): string {
  569. if ($path === '') {
  570. return $path;
  571. }
  572. $path = Filesystem::normalizePath($path);
  573. // remove leading slash
  574. return substr($path, 1);
  575. }
  576. /**
  577. * URL encodes the given path but keeps the slashes
  578. *
  579. * @param string $path to encode
  580. * @return string encoded path
  581. */
  582. protected function encodePath(string $path): string {
  583. // slashes need to stay
  584. return str_replace('%2F', '/', rawurlencode($path));
  585. }
  586. /**
  587. * @return bool
  588. * @throws StorageInvalidException
  589. * @throws StorageNotAvailableException
  590. */
  591. protected function simpleResponse(string $method, string $path, ?string $body, int $expected): bool {
  592. $path = $this->cleanPath($path);
  593. try {
  594. $response = $this->client->request($method, $this->encodePath($path), $body);
  595. return $response['statusCode'] == $expected;
  596. } catch (ClientHttpException $e) {
  597. if ($e->getHttpStatus() === 404 && $method === 'DELETE') {
  598. $this->statCache->clear($path . '/');
  599. $this->statCache->set($path, false);
  600. return false;
  601. }
  602. $this->convertException($e, $path);
  603. } catch (\Exception $e) {
  604. $this->convertException($e, $path);
  605. }
  606. return false;
  607. }
  608. /**
  609. * check if curl is installed
  610. */
  611. public static function checkDependencies(): bool {
  612. return true;
  613. }
  614. public function isUpdatable(string $path): bool {
  615. return (bool)($this->getPermissions($path) & Constants::PERMISSION_UPDATE);
  616. }
  617. public function isCreatable(string $path): bool {
  618. return (bool)($this->getPermissions($path) & Constants::PERMISSION_CREATE);
  619. }
  620. public function isSharable(string $path): bool {
  621. return (bool)($this->getPermissions($path) & Constants::PERMISSION_SHARE);
  622. }
  623. public function isDeletable(string $path): bool {
  624. return (bool)($this->getPermissions($path) & Constants::PERMISSION_DELETE);
  625. }
  626. public function getPermissions(string $path): int {
  627. $stat = $this->getMetaData($path);
  628. return $stat ? $stat['permissions'] : 0;
  629. }
  630. public function getETag(string $path): string|false {
  631. $meta = $this->getMetaData($path);
  632. return $meta ? $meta['etag'] : false;
  633. }
  634. protected function parsePermissions(string $permissionsString): int {
  635. $permissions = Constants::PERMISSION_READ;
  636. if (str_contains($permissionsString, 'R')) {
  637. $permissions |= Constants::PERMISSION_SHARE;
  638. }
  639. if (str_contains($permissionsString, 'D')) {
  640. $permissions |= Constants::PERMISSION_DELETE;
  641. }
  642. if (str_contains($permissionsString, 'W')) {
  643. $permissions |= Constants::PERMISSION_UPDATE;
  644. }
  645. if (str_contains($permissionsString, 'CK')) {
  646. $permissions |= Constants::PERMISSION_CREATE;
  647. $permissions |= Constants::PERMISSION_UPDATE;
  648. }
  649. return $permissions;
  650. }
  651. public function hasUpdated(string $path, int $time): bool {
  652. $this->init();
  653. $path = $this->cleanPath($path);
  654. try {
  655. // force refresh for $path
  656. $this->statCache->remove($path);
  657. $response = $this->propfind($path);
  658. if ($response === false) {
  659. if ($path === '') {
  660. // if root is gone it means the storage is not available
  661. throw new StorageNotAvailableException('root is gone');
  662. }
  663. return false;
  664. }
  665. if (isset($response['{DAV:}getetag'])) {
  666. $cachedData = $this->getCache()->get($path);
  667. $etag = trim($response['{DAV:}getetag'], '"');
  668. if (($cachedData === false) || (!empty($etag) && ($cachedData['etag'] !== $etag))) {
  669. return true;
  670. } elseif (isset($response['{http://open-collaboration-services.org/ns}share-permissions'])) {
  671. $sharePermissions = (int)$response['{http://open-collaboration-services.org/ns}share-permissions'];
  672. return $sharePermissions !== $cachedData['permissions'];
  673. } elseif (isset($response['{http://owncloud.org/ns}permissions'])) {
  674. $permissions = $this->parsePermissions($response['{http://owncloud.org/ns}permissions']);
  675. return $permissions !== $cachedData['permissions'];
  676. } else {
  677. return false;
  678. }
  679. } elseif (isset($response['{DAV:}getlastmodified'])) {
  680. $remoteMtime = strtotime($response['{DAV:}getlastmodified']);
  681. return $remoteMtime > $time;
  682. } else {
  683. // neither `getetag` nor `getlastmodified` is set
  684. return false;
  685. }
  686. } catch (ClientHttpException $e) {
  687. if ($e->getHttpStatus() === 405) {
  688. if ($path === '') {
  689. // if root is gone it means the storage is not available
  690. throw new StorageNotAvailableException(get_class($e) . ': ' . $e->getMessage());
  691. }
  692. return false;
  693. }
  694. $this->convertException($e, $path);
  695. return false;
  696. } catch (\Exception $e) {
  697. $this->convertException($e, $path);
  698. return false;
  699. }
  700. }
  701. /**
  702. * Interpret the given exception and decide whether it is due to an
  703. * unavailable storage, invalid storage or other.
  704. * This will either throw StorageInvalidException, StorageNotAvailableException
  705. * or do nothing.
  706. *
  707. * @param Exception $e sabre exception
  708. * @param string $path optional path from the operation
  709. *
  710. * @throws StorageInvalidException if the storage is invalid, for example
  711. * when the authentication expired or is invalid
  712. * @throws StorageNotAvailableException if the storage is not available,
  713. * which might be temporary
  714. * @throws ForbiddenException if the action is not allowed
  715. */
  716. protected function convertException(Exception $e, string $path = ''): void {
  717. $this->logger->debug($e->getMessage(), ['app' => 'files_external', 'exception' => $e]);
  718. if ($e instanceof ClientHttpException) {
  719. if ($e->getHttpStatus() === Http::STATUS_LOCKED) {
  720. throw new \OCP\Lock\LockedException($path);
  721. }
  722. if ($e->getHttpStatus() === Http::STATUS_UNAUTHORIZED) {
  723. // either password was changed or was invalid all along
  724. throw new StorageInvalidException(get_class($e) . ': ' . $e->getMessage());
  725. } elseif ($e->getHttpStatus() === Http::STATUS_METHOD_NOT_ALLOWED) {
  726. // ignore exception for MethodNotAllowed, false will be returned
  727. return;
  728. } elseif ($e->getHttpStatus() === Http::STATUS_FORBIDDEN) {
  729. // The operation is forbidden. Fail somewhat gracefully
  730. throw new ForbiddenException(get_class($e) . ':' . $e->getMessage(), false);
  731. }
  732. throw new StorageNotAvailableException(get_class($e) . ': ' . $e->getMessage());
  733. } elseif ($e instanceof ClientException) {
  734. // connection timeout or refused, server could be temporarily down
  735. throw new StorageNotAvailableException(get_class($e) . ': ' . $e->getMessage());
  736. } elseif ($e instanceof \InvalidArgumentException) {
  737. // parse error because the server returned HTML instead of XML,
  738. // possibly temporarily down
  739. throw new StorageNotAvailableException(get_class($e) . ': ' . $e->getMessage());
  740. } elseif (($e instanceof StorageNotAvailableException) || ($e instanceof StorageInvalidException)) {
  741. // rethrow
  742. throw $e;
  743. }
  744. // TODO: only log for now, but in the future need to wrap/rethrow exception
  745. }
  746. public function getDirectoryContent(string $directory): \Traversable {
  747. $this->init();
  748. $directory = $this->cleanPath($directory);
  749. try {
  750. $responses = $this->client->propFind(
  751. $this->encodePath($directory),
  752. self::PROPFIND_PROPS,
  753. 1
  754. );
  755. array_shift($responses); //the first entry is the current directory
  756. if (!$this->statCache->hasKey($directory)) {
  757. $this->statCache->set($directory, true);
  758. }
  759. foreach ($responses as $file => $response) {
  760. $file = rawurldecode($file);
  761. $file = substr($file, strlen($this->root));
  762. $file = $this->cleanPath($file);
  763. $this->statCache->set($file, $response);
  764. yield $this->getMetaFromPropfind($file, $response);
  765. }
  766. } catch (\Exception $e) {
  767. $this->convertException($e, $directory);
  768. }
  769. }
  770. }