CustomPropertiesBackend.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
  5. * SPDX-License-Identifier: AGPL-3.0-only
  6. */
  7. namespace OCA\DAV\DAV;
  8. use Exception;
  9. use OCA\DAV\Connector\Sabre\Directory;
  10. use OCA\DAV\Connector\Sabre\FilesPlugin;
  11. use OCP\DB\QueryBuilder\IQueryBuilder;
  12. use OCP\IDBConnection;
  13. use OCP\IUser;
  14. use Sabre\CalDAV\ICalendar;
  15. use Sabre\DAV\Exception as DavException;
  16. use Sabre\DAV\PropertyStorage\Backend\BackendInterface;
  17. use Sabre\DAV\PropFind;
  18. use Sabre\DAV\PropPatch;
  19. use Sabre\DAV\Server;
  20. use Sabre\DAV\Tree;
  21. use Sabre\DAV\Xml\Property\Complex;
  22. use Sabre\DAV\Xml\Property\Href;
  23. use Sabre\DAV\Xml\Property\LocalHref;
  24. use Sabre\Xml\ParseException;
  25. use Sabre\Xml\Service as XmlService;
  26. use function array_intersect;
  27. class CustomPropertiesBackend implements BackendInterface {
  28. /** @var string */
  29. private const TABLE_NAME = 'properties';
  30. /**
  31. * Value is stored as string.
  32. */
  33. public const PROPERTY_TYPE_STRING = 1;
  34. /**
  35. * Value is stored as XML fragment.
  36. */
  37. public const PROPERTY_TYPE_XML = 2;
  38. /**
  39. * Value is stored as a property object.
  40. */
  41. public const PROPERTY_TYPE_OBJECT = 3;
  42. /**
  43. * Value is stored as a {DAV:}href string.
  44. */
  45. public const PROPERTY_TYPE_HREF = 4;
  46. /**
  47. * Ignored properties
  48. *
  49. * @var string[]
  50. */
  51. private const IGNORED_PROPERTIES = [
  52. '{DAV:}getcontentlength',
  53. '{DAV:}getcontenttype',
  54. '{DAV:}getetag',
  55. '{DAV:}quota-used-bytes',
  56. '{DAV:}quota-available-bytes',
  57. '{http://owncloud.org/ns}permissions',
  58. '{http://owncloud.org/ns}downloadURL',
  59. '{http://owncloud.org/ns}dDC',
  60. '{http://owncloud.org/ns}size',
  61. '{http://nextcloud.org/ns}is-encrypted',
  62. // Currently, returning null from any propfind handler would still trigger the backend,
  63. // so we add all known Nextcloud custom properties in here to avoid that
  64. // text app
  65. '{http://nextcloud.org/ns}rich-workspace',
  66. '{http://nextcloud.org/ns}rich-workspace-file',
  67. // groupfolders
  68. '{http://nextcloud.org/ns}acl-enabled',
  69. '{http://nextcloud.org/ns}acl-can-manage',
  70. '{http://nextcloud.org/ns}acl-list',
  71. '{http://nextcloud.org/ns}inherited-acl-list',
  72. '{http://nextcloud.org/ns}group-folder-id',
  73. // files_lock
  74. '{http://nextcloud.org/ns}lock',
  75. '{http://nextcloud.org/ns}lock-owner-type',
  76. '{http://nextcloud.org/ns}lock-owner',
  77. '{http://nextcloud.org/ns}lock-owner-displayname',
  78. '{http://nextcloud.org/ns}lock-owner-editor',
  79. '{http://nextcloud.org/ns}lock-time',
  80. '{http://nextcloud.org/ns}lock-timeout',
  81. '{http://nextcloud.org/ns}lock-token',
  82. ];
  83. /**
  84. * Properties set by one user, readable by all others
  85. *
  86. * @var string[]
  87. */
  88. private const PUBLISHED_READ_ONLY_PROPERTIES = [
  89. '{urn:ietf:params:xml:ns:caldav}calendar-availability',
  90. '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
  91. ];
  92. /**
  93. * Map of custom XML elements to parse when trying to deserialize an instance of
  94. * \Sabre\DAV\Xml\Property\Complex to find a more specialized PROPERTY_TYPE_*
  95. */
  96. private const COMPLEX_XML_ELEMENT_MAP = [
  97. '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL' => Href::class,
  98. ];
  99. /**
  100. * @var Tree
  101. */
  102. private $tree;
  103. /**
  104. * @var IDBConnection
  105. */
  106. private $connection;
  107. /**
  108. * @var IUser
  109. */
  110. private $user;
  111. /**
  112. * Properties cache
  113. *
  114. * @var array
  115. */
  116. private $userCache = [];
  117. private Server $server;
  118. private XmlService $xmlService;
  119. /**
  120. * @param Tree $tree node tree
  121. * @param IDBConnection $connection database connection
  122. * @param IUser $user owner of the tree and properties
  123. */
  124. public function __construct(
  125. Server $server,
  126. Tree $tree,
  127. IDBConnection $connection,
  128. IUser $user,
  129. ) {
  130. $this->server = $server;
  131. $this->tree = $tree;
  132. $this->connection = $connection;
  133. $this->user = $user;
  134. $this->xmlService = new XmlService();
  135. $this->xmlService->elementMap = array_merge(
  136. $this->xmlService->elementMap,
  137. self::COMPLEX_XML_ELEMENT_MAP,
  138. );
  139. }
  140. /**
  141. * Fetches properties for a path.
  142. *
  143. * @param string $path
  144. * @param PropFind $propFind
  145. * @return void
  146. */
  147. public function propFind($path, PropFind $propFind) {
  148. $requestedProps = $propFind->get404Properties();
  149. // these might appear
  150. $requestedProps = array_diff(
  151. $requestedProps,
  152. self::IGNORED_PROPERTIES,
  153. );
  154. $requestedProps = array_filter(
  155. $requestedProps,
  156. fn ($prop) => !str_starts_with($prop, FilesPlugin::FILE_METADATA_PREFIX),
  157. );
  158. // substr of calendars/ => path is inside the CalDAV component
  159. // two '/' => this a calendar (no calendar-home nor calendar object)
  160. if (str_starts_with($path, 'calendars/') && substr_count($path, '/') === 2) {
  161. $allRequestedProps = $propFind->getRequestedProperties();
  162. $customPropertiesForShares = [
  163. '{DAV:}displayname',
  164. '{urn:ietf:params:xml:ns:caldav}calendar-description',
  165. '{urn:ietf:params:xml:ns:caldav}calendar-timezone',
  166. '{http://apple.com/ns/ical/}calendar-order',
  167. '{http://apple.com/ns/ical/}calendar-color',
  168. '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp',
  169. ];
  170. foreach ($customPropertiesForShares as $customPropertyForShares) {
  171. if (in_array($customPropertyForShares, $allRequestedProps)) {
  172. $requestedProps[] = $customPropertyForShares;
  173. }
  174. }
  175. }
  176. // substr of addressbooks/ => path is inside the CardDAV component
  177. // three '/' => this a addressbook (no addressbook-home nor contact object)
  178. if (str_starts_with($path, 'addressbooks/') && substr_count($path, '/') === 3) {
  179. $allRequestedProps = $propFind->getRequestedProperties();
  180. $customPropertiesForShares = [
  181. '{DAV:}displayname',
  182. ];
  183. foreach ($customPropertiesForShares as $customPropertyForShares) {
  184. if (in_array($customPropertyForShares, $allRequestedProps, true)) {
  185. $requestedProps[] = $customPropertyForShares;
  186. }
  187. }
  188. }
  189. // substr of principals/users/ => path is a user principal
  190. // two '/' => this a principal collection (and not some child object)
  191. if (str_starts_with($path, 'principals/users/') && substr_count($path, '/') === 2) {
  192. $allRequestedProps = $propFind->getRequestedProperties();
  193. $customProperties = [
  194. '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL',
  195. ];
  196. foreach ($customProperties as $customProperty) {
  197. if (in_array($customProperty, $allRequestedProps, true)) {
  198. $requestedProps[] = $customProperty;
  199. }
  200. }
  201. }
  202. if (empty($requestedProps)) {
  203. return;
  204. }
  205. $node = $this->tree->getNodeForPath($path);
  206. if ($node instanceof Directory && $propFind->getDepth() !== 0) {
  207. $this->cacheDirectory($path, $node);
  208. }
  209. // First fetch the published properties (set by another user), then get the ones set by
  210. // the current user. If both are set then the latter as priority.
  211. foreach ($this->getPublishedProperties($path, $requestedProps) as $propName => $propValue) {
  212. try {
  213. $this->validateProperty($path, $propName, $propValue);
  214. } catch (DavException $e) {
  215. continue;
  216. }
  217. $propFind->set($propName, $propValue);
  218. }
  219. foreach ($this->getUserProperties($path, $requestedProps) as $propName => $propValue) {
  220. try {
  221. $this->validateProperty($path, $propName, $propValue);
  222. } catch (DavException $e) {
  223. continue;
  224. }
  225. $propFind->set($propName, $propValue);
  226. }
  227. }
  228. /**
  229. * Updates properties for a path
  230. *
  231. * @param string $path
  232. * @param PropPatch $propPatch
  233. *
  234. * @return void
  235. */
  236. public function propPatch($path, PropPatch $propPatch) {
  237. $propPatch->handleRemaining(function ($changedProps) use ($path) {
  238. return $this->updateProperties($path, $changedProps);
  239. });
  240. }
  241. /**
  242. * This method is called after a node is deleted.
  243. *
  244. * @param string $path path of node for which to delete properties
  245. */
  246. public function delete($path) {
  247. $statement = $this->connection->prepare(
  248. 'DELETE FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?'
  249. );
  250. $statement->execute([$this->user->getUID(), $this->formatPath($path)]);
  251. $statement->closeCursor();
  252. unset($this->userCache[$path]);
  253. }
  254. /**
  255. * This method is called after a successful MOVE
  256. *
  257. * @param string $source
  258. * @param string $destination
  259. *
  260. * @return void
  261. */
  262. public function move($source, $destination) {
  263. $statement = $this->connection->prepare(
  264. 'UPDATE `*PREFIX*properties` SET `propertypath` = ?' .
  265. ' WHERE `userid` = ? AND `propertypath` = ?'
  266. );
  267. $statement->execute([$this->formatPath($destination), $this->user->getUID(), $this->formatPath($source)]);
  268. $statement->closeCursor();
  269. }
  270. /**
  271. * Validate the value of a property. Will throw if a value is invalid.
  272. *
  273. * @throws DavException The value of the property is invalid
  274. */
  275. private function validateProperty(string $path, string $propName, mixed $propValue): void {
  276. switch ($propName) {
  277. case '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL':
  278. /** @var Href $propValue */
  279. $href = $propValue->getHref();
  280. if ($href === null) {
  281. throw new DavException('Href is empty');
  282. }
  283. // $path is the principal here as this prop is only set on principals
  284. $node = $this->tree->getNodeForPath($href);
  285. if (!($node instanceof ICalendar) || $node->getOwner() !== $path) {
  286. throw new DavException('No such calendar');
  287. }
  288. break;
  289. }
  290. }
  291. /**
  292. * @param string $path
  293. * @param string[] $requestedProperties
  294. *
  295. * @return array
  296. */
  297. private function getPublishedProperties(string $path, array $requestedProperties): array {
  298. $allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
  299. if (empty($allowedProps)) {
  300. return [];
  301. }
  302. $qb = $this->connection->getQueryBuilder();
  303. $qb->select('*')
  304. ->from(self::TABLE_NAME)
  305. ->where($qb->expr()->eq('propertypath', $qb->createNamedParameter($path)));
  306. $result = $qb->executeQuery();
  307. $props = [];
  308. while ($row = $result->fetch()) {
  309. $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
  310. }
  311. $result->closeCursor();
  312. return $props;
  313. }
  314. /**
  315. * prefetch all user properties in a directory
  316. */
  317. private function cacheDirectory(string $path, Directory $node): void {
  318. $prefix = ltrim($path . '/', '/');
  319. $query = $this->connection->getQueryBuilder();
  320. $query->select('name', 'propertypath', 'propertyname', 'propertyvalue', 'valuetype')
  321. ->from('filecache', 'f')
  322. ->leftJoin('f', 'properties', 'p', $query->expr()->andX(
  323. $query->expr()->eq('propertypath', $query->func()->concat(
  324. $query->createNamedParameter($prefix),
  325. 'name'
  326. )),
  327. $query->expr()->eq('userid', $query->createNamedParameter($this->user->getUID()))
  328. ))
  329. ->where($query->expr()->eq('parent', $query->createNamedParameter($node->getInternalFileId(), IQueryBuilder::PARAM_INT)));
  330. $result = $query->executeQuery();
  331. $propsByPath = [];
  332. while ($row = $result->fetch()) {
  333. $childPath = $prefix . $row['name'];
  334. if (!isset($propsByPath[$childPath])) {
  335. $propsByPath[$childPath] = [];
  336. }
  337. if (isset($row['propertyname'])) {
  338. $propsByPath[$childPath][$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
  339. }
  340. }
  341. $this->userCache = array_merge($this->userCache, $propsByPath);
  342. }
  343. /**
  344. * Returns a list of properties for the given path and current user
  345. *
  346. * @param string $path
  347. * @param array $requestedProperties requested properties or empty array for "all"
  348. * @return array
  349. * @note The properties list is a list of propertynames the client
  350. * requested, encoded as xmlnamespace#tagName, for example:
  351. * http://www.example.org/namespace#author If the array is empty, all
  352. * properties should be returned
  353. */
  354. private function getUserProperties(string $path, array $requestedProperties) {
  355. if (isset($this->userCache[$path])) {
  356. return $this->userCache[$path];
  357. }
  358. // TODO: chunking if more than 1000 properties
  359. $sql = 'SELECT * FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?';
  360. $whereValues = [$this->user->getUID(), $this->formatPath($path)];
  361. $whereTypes = [null, null];
  362. if (!empty($requestedProperties)) {
  363. // request only a subset
  364. $sql .= ' AND `propertyname` in (?)';
  365. $whereValues[] = $requestedProperties;
  366. $whereTypes[] = \Doctrine\DBAL\Connection::PARAM_STR_ARRAY;
  367. }
  368. $result = $this->connection->executeQuery(
  369. $sql,
  370. $whereValues,
  371. $whereTypes
  372. );
  373. $props = [];
  374. while ($row = $result->fetch()) {
  375. $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
  376. }
  377. $result->closeCursor();
  378. $this->userCache[$path] = $props;
  379. return $props;
  380. }
  381. /**
  382. * @throws Exception
  383. */
  384. private function updateProperties(string $path, array $properties): bool {
  385. // TODO: use "insert or update" strategy ?
  386. $existing = $this->getUserProperties($path, []);
  387. try {
  388. $this->connection->beginTransaction();
  389. foreach ($properties as $propertyName => $propertyValue) {
  390. // common parameters for all queries
  391. $dbParameters = [
  392. 'userid' => $this->user->getUID(),
  393. 'propertyPath' => $this->formatPath($path),
  394. 'propertyName' => $propertyName,
  395. ];
  396. // If it was null, we need to delete the property
  397. if (is_null($propertyValue)) {
  398. if (array_key_exists($propertyName, $existing)) {
  399. $deleteQuery = $deleteQuery ?? $this->createDeleteQuery();
  400. $deleteQuery
  401. ->setParameters($dbParameters)
  402. ->executeStatement();
  403. }
  404. } else {
  405. [$value, $valueType] = $this->encodeValueForDatabase(
  406. $path,
  407. $propertyName,
  408. $propertyValue,
  409. );
  410. $dbParameters['propertyValue'] = $value;
  411. $dbParameters['valueType'] = $valueType;
  412. if (!array_key_exists($propertyName, $existing)) {
  413. $insertQuery = $insertQuery ?? $this->createInsertQuery();
  414. $insertQuery
  415. ->setParameters($dbParameters)
  416. ->executeStatement();
  417. } else {
  418. $updateQuery = $updateQuery ?? $this->createUpdateQuery();
  419. $updateQuery
  420. ->setParameters($dbParameters)
  421. ->executeStatement();
  422. }
  423. }
  424. }
  425. $this->connection->commit();
  426. unset($this->userCache[$path]);
  427. } catch (Exception $e) {
  428. $this->connection->rollBack();
  429. throw $e;
  430. }
  431. return true;
  432. }
  433. /**
  434. * long paths are hashed to ensure they fit in the database
  435. *
  436. * @param string $path
  437. * @return string
  438. */
  439. private function formatPath(string $path): string {
  440. if (strlen($path) > 250) {
  441. return sha1($path);
  442. }
  443. return $path;
  444. }
  445. /**
  446. * @throws ParseException If parsing a \Sabre\DAV\Xml\Property\Complex value fails
  447. * @throws DavException If the property value is invalid
  448. */
  449. private function encodeValueForDatabase(string $path, string $name, mixed $value): array {
  450. // Try to parse a more specialized property type first
  451. if ($value instanceof Complex) {
  452. $xml = $this->xmlService->write($name, [$value], $this->server->getBaseUri());
  453. $value = $this->xmlService->parse($xml, $this->server->getBaseUri()) ?? $value;
  454. }
  455. if ($name === '{urn:ietf:params:xml:ns:caldav}schedule-default-calendar-URL') {
  456. $value = $this->encodeDefaultCalendarUrl($value);
  457. }
  458. try {
  459. $this->validateProperty($path, $name, $value);
  460. } catch (DavException $e) {
  461. throw new DavException(
  462. "Property \"$name\" has an invalid value: " . $e->getMessage(),
  463. 0,
  464. $e,
  465. );
  466. }
  467. if (is_scalar($value)) {
  468. $valueType = self::PROPERTY_TYPE_STRING;
  469. } elseif ($value instanceof Complex) {
  470. $valueType = self::PROPERTY_TYPE_XML;
  471. $value = $value->getXml();
  472. } elseif ($value instanceof Href) {
  473. $valueType = self::PROPERTY_TYPE_HREF;
  474. $value = $value->getHref();
  475. } else {
  476. $valueType = self::PROPERTY_TYPE_OBJECT;
  477. $value = serialize($value);
  478. }
  479. return [$value, $valueType];
  480. }
  481. /**
  482. * @return mixed|Complex|string
  483. */
  484. private function decodeValueFromDatabase(string $value, int $valueType) {
  485. switch ($valueType) {
  486. case self::PROPERTY_TYPE_XML:
  487. return new Complex($value);
  488. case self::PROPERTY_TYPE_HREF:
  489. return new Href($value);
  490. case self::PROPERTY_TYPE_OBJECT:
  491. return unserialize($value);
  492. case self::PROPERTY_TYPE_STRING:
  493. default:
  494. return $value;
  495. }
  496. }
  497. private function encodeDefaultCalendarUrl(Href $value): Href {
  498. $href = $value->getHref();
  499. if ($href === null) {
  500. return $value;
  501. }
  502. if (!str_starts_with($href, '/')) {
  503. return $value;
  504. }
  505. try {
  506. // Build path relative to the dav base URI to be used later to find the node
  507. $value = new LocalHref($this->server->calculateUri($href) . '/');
  508. } catch (DavException\Forbidden) {
  509. // Not existing calendars will be handled later when the value is validated
  510. }
  511. return $value;
  512. }
  513. private function createDeleteQuery(): IQueryBuilder {
  514. $deleteQuery = $this->connection->getQueryBuilder();
  515. $deleteQuery->delete('properties')
  516. ->where($deleteQuery->expr()->eq('userid', $deleteQuery->createParameter('userid')))
  517. ->andWhere($deleteQuery->expr()->eq('propertypath', $deleteQuery->createParameter('propertyPath')))
  518. ->andWhere($deleteQuery->expr()->eq('propertyname', $deleteQuery->createParameter('propertyName')));
  519. return $deleteQuery;
  520. }
  521. private function createInsertQuery(): IQueryBuilder {
  522. $insertQuery = $this->connection->getQueryBuilder();
  523. $insertQuery->insert('properties')
  524. ->values([
  525. 'userid' => $insertQuery->createParameter('userid'),
  526. 'propertypath' => $insertQuery->createParameter('propertyPath'),
  527. 'propertyname' => $insertQuery->createParameter('propertyName'),
  528. 'propertyvalue' => $insertQuery->createParameter('propertyValue'),
  529. 'valuetype' => $insertQuery->createParameter('valueType'),
  530. ]);
  531. return $insertQuery;
  532. }
  533. private function createUpdateQuery(): IQueryBuilder {
  534. $updateQuery = $this->connection->getQueryBuilder();
  535. $updateQuery->update('properties')
  536. ->set('propertyvalue', $updateQuery->createParameter('propertyValue'))
  537. ->set('valuetype', $updateQuery->createParameter('valueType'))
  538. ->where($updateQuery->expr()->eq('userid', $updateQuery->createParameter('userid')))
  539. ->andWhere($updateQuery->expr()->eq('propertypath', $updateQuery->createParameter('propertyPath')))
  540. ->andWhere($updateQuery->expr()->eq('propertyname', $updateQuery->createParameter('propertyName')));
  541. return $updateQuery;
  542. }
  543. }