1
0

CustomPropertiesBackend.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. <?php
  2. /**
  3. * @copyright Copyright (c) 2016, ownCloud, Inc.
  4. * @copyright Copyright (c) 2017, Georg Ehrke <oc.list@georgehrke.com>
  5. *
  6. * @author Georg Ehrke <oc.list@georgehrke.com>
  7. * @author Robin Appelman <robin@icewind.nl>
  8. * @author Thomas Müller <thomas.mueller@tmit.eu>
  9. *
  10. * @license AGPL-3.0
  11. *
  12. * This code is free software: you can redistribute it and/or modify
  13. * it under the terms of the GNU Affero General Public License, version 3,
  14. * as published by the Free Software Foundation.
  15. *
  16. * This program is distributed in the hope that it will be useful,
  17. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  18. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  19. * GNU Affero General Public License for more details.
  20. *
  21. * You should have received a copy of the GNU Affero General Public License, version 3,
  22. * along with this program. If not, see <http://www.gnu.org/licenses/>
  23. *
  24. */
  25. namespace OCA\DAV\DAV;
  26. use Exception;
  27. use OCA\DAV\Connector\Sabre\Directory;
  28. use OCP\DB\QueryBuilder\IQueryBuilder;
  29. use OCP\IDBConnection;
  30. use OCP\IUser;
  31. use Sabre\DAV\PropertyStorage\Backend\BackendInterface;
  32. use Sabre\DAV\PropFind;
  33. use Sabre\DAV\PropPatch;
  34. use Sabre\DAV\Tree;
  35. use Sabre\DAV\Xml\Property\Complex;
  36. use function array_intersect;
  37. class CustomPropertiesBackend implements BackendInterface {
  38. /** @var string */
  39. private const TABLE_NAME = 'properties';
  40. /**
  41. * Value is stored as string.
  42. */
  43. public const PROPERTY_TYPE_STRING = 1;
  44. /**
  45. * Value is stored as XML fragment.
  46. */
  47. public const PROPERTY_TYPE_XML = 2;
  48. /**
  49. * Value is stored as a property object.
  50. */
  51. public const PROPERTY_TYPE_OBJECT = 3;
  52. /**
  53. * Ignored properties
  54. *
  55. * @var string[]
  56. */
  57. private const IGNORED_PROPERTIES = [
  58. '{DAV:}getcontentlength',
  59. '{DAV:}getcontenttype',
  60. '{DAV:}getetag',
  61. '{DAV:}quota-used-bytes',
  62. '{DAV:}quota-available-bytes',
  63. '{http://owncloud.org/ns}permissions',
  64. '{http://owncloud.org/ns}downloadURL',
  65. '{http://owncloud.org/ns}dDC',
  66. '{http://owncloud.org/ns}size',
  67. '{http://nextcloud.org/ns}is-encrypted',
  68. // Currently, returning null from any propfind handler would still trigger the backend,
  69. // so we add all known Nextcloud custom properties in here to avoid that
  70. // text app
  71. '{http://nextcloud.org/ns}rich-workspace',
  72. '{http://nextcloud.org/ns}rich-workspace-file',
  73. // groupfolders
  74. '{http://nextcloud.org/ns}acl-enabled',
  75. '{http://nextcloud.org/ns}acl-can-manage',
  76. '{http://nextcloud.org/ns}acl-list',
  77. '{http://nextcloud.org/ns}inherited-acl-list',
  78. '{http://nextcloud.org/ns}group-folder-id',
  79. // files_lock
  80. '{http://nextcloud.org/ns}lock',
  81. '{http://nextcloud.org/ns}lock-owner-type',
  82. '{http://nextcloud.org/ns}lock-owner',
  83. '{http://nextcloud.org/ns}lock-owner-displayname',
  84. '{http://nextcloud.org/ns}lock-owner-editor',
  85. '{http://nextcloud.org/ns}lock-time',
  86. '{http://nextcloud.org/ns}lock-timeout',
  87. '{http://nextcloud.org/ns}lock-token',
  88. ];
  89. /**
  90. * Properties set by one user, readable by all others
  91. *
  92. * @var string[]
  93. */
  94. private const PUBLISHED_READ_ONLY_PROPERTIES = [
  95. '{urn:ietf:params:xml:ns:caldav}calendar-availability',
  96. ];
  97. /**
  98. * @var Tree
  99. */
  100. private $tree;
  101. /**
  102. * @var IDBConnection
  103. */
  104. private $connection;
  105. /**
  106. * @var IUser
  107. */
  108. private $user;
  109. /**
  110. * Properties cache
  111. *
  112. * @var array
  113. */
  114. private $userCache = [];
  115. /**
  116. * @param Tree $tree node tree
  117. * @param IDBConnection $connection database connection
  118. * @param IUser $user owner of the tree and properties
  119. */
  120. public function __construct(
  121. Tree $tree,
  122. IDBConnection $connection,
  123. IUser $user
  124. ) {
  125. $this->tree = $tree;
  126. $this->connection = $connection;
  127. $this->user = $user;
  128. }
  129. /**
  130. * Fetches properties for a path.
  131. *
  132. * @param string $path
  133. * @param PropFind $propFind
  134. * @return void
  135. */
  136. public function propFind($path, PropFind $propFind) {
  137. $requestedProps = $propFind->get404Properties();
  138. // these might appear
  139. $requestedProps = array_diff(
  140. $requestedProps,
  141. self::IGNORED_PROPERTIES
  142. );
  143. // substr of calendars/ => path is inside the CalDAV component
  144. // two '/' => this a calendar (no calendar-home nor calendar object)
  145. if (substr($path, 0, 10) === 'calendars/' && substr_count($path, '/') === 2) {
  146. $allRequestedProps = $propFind->getRequestedProperties();
  147. $customPropertiesForShares = [
  148. '{DAV:}displayname',
  149. '{urn:ietf:params:xml:ns:caldav}calendar-description',
  150. '{urn:ietf:params:xml:ns:caldav}calendar-timezone',
  151. '{http://apple.com/ns/ical/}calendar-order',
  152. '{http://apple.com/ns/ical/}calendar-color',
  153. '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp',
  154. ];
  155. foreach ($customPropertiesForShares as $customPropertyForShares) {
  156. if (in_array($customPropertyForShares, $allRequestedProps)) {
  157. $requestedProps[] = $customPropertyForShares;
  158. }
  159. }
  160. }
  161. // substr of addressbooks/ => path is inside the CardDAV component
  162. // three '/' => this a addressbook (no addressbook-home nor contact object)
  163. if (str_starts_with($path, 'addressbooks/') && substr_count($path, '/') === 3) {
  164. $allRequestedProps = $propFind->getRequestedProperties();
  165. $customPropertiesForShares = [
  166. '{DAV:}displayname',
  167. ];
  168. foreach ($customPropertiesForShares as $customPropertyForShares) {
  169. if (in_array($customPropertyForShares, $allRequestedProps, true)) {
  170. $requestedProps[] = $customPropertyForShares;
  171. }
  172. }
  173. }
  174. if (empty($requestedProps)) {
  175. return;
  176. }
  177. $node = $this->tree->getNodeForPath($path);
  178. if ($node instanceof Directory && $propFind->getDepth() !== 0) {
  179. $this->cacheDirectory($path, $node);
  180. }
  181. // First fetch the published properties (set by another user), then get the ones set by
  182. // the current user. If both are set then the latter as priority.
  183. foreach ($this->getPublishedProperties($path, $requestedProps) as $propName => $propValue) {
  184. $propFind->set($propName, $propValue);
  185. }
  186. foreach ($this->getUserProperties($path, $requestedProps) as $propName => $propValue) {
  187. $propFind->set($propName, $propValue);
  188. }
  189. }
  190. /**
  191. * Updates properties for a path
  192. *
  193. * @param string $path
  194. * @param PropPatch $propPatch
  195. *
  196. * @return void
  197. */
  198. public function propPatch($path, PropPatch $propPatch) {
  199. $propPatch->handleRemaining(function ($changedProps) use ($path) {
  200. return $this->updateProperties($path, $changedProps);
  201. });
  202. }
  203. /**
  204. * This method is called after a node is deleted.
  205. *
  206. * @param string $path path of node for which to delete properties
  207. */
  208. public function delete($path) {
  209. $statement = $this->connection->prepare(
  210. 'DELETE FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?'
  211. );
  212. $statement->execute([$this->user->getUID(), $this->formatPath($path)]);
  213. $statement->closeCursor();
  214. unset($this->userCache[$path]);
  215. }
  216. /**
  217. * This method is called after a successful MOVE
  218. *
  219. * @param string $source
  220. * @param string $destination
  221. *
  222. * @return void
  223. */
  224. public function move($source, $destination) {
  225. $statement = $this->connection->prepare(
  226. 'UPDATE `*PREFIX*properties` SET `propertypath` = ?' .
  227. ' WHERE `userid` = ? AND `propertypath` = ?'
  228. );
  229. $statement->execute([$this->formatPath($destination), $this->user->getUID(), $this->formatPath($source)]);
  230. $statement->closeCursor();
  231. }
  232. /**
  233. * @param string $path
  234. * @param string[] $requestedProperties
  235. *
  236. * @return array
  237. */
  238. private function getPublishedProperties(string $path, array $requestedProperties): array {
  239. $allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
  240. if (empty($allowedProps)) {
  241. return [];
  242. }
  243. $qb = $this->connection->getQueryBuilder();
  244. $qb->select('*')
  245. ->from(self::TABLE_NAME)
  246. ->where($qb->expr()->eq('propertypath', $qb->createNamedParameter($path)));
  247. $result = $qb->executeQuery();
  248. $props = [];
  249. while ($row = $result->fetch()) {
  250. $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
  251. }
  252. $result->closeCursor();
  253. return $props;
  254. }
  255. /**
  256. * prefetch all user properties in a directory
  257. */
  258. private function cacheDirectory(string $path, Directory $node): void {
  259. $prefix = ltrim($path . '/', '/');
  260. $query = $this->connection->getQueryBuilder();
  261. $query->select('name', 'propertypath', 'propertyname', 'propertyvalue', 'valuetype')
  262. ->from('filecache', 'f')
  263. ->leftJoin('f', 'properties', 'p', $query->expr()->andX(
  264. $query->expr()->eq('propertypath', $query->func()->concat(
  265. $query->createNamedParameter($prefix),
  266. 'name'
  267. )),
  268. $query->expr()->eq('userid', $query->createNamedParameter($this->user->getUID()))
  269. ))
  270. ->where($query->expr()->eq('parent', $query->createNamedParameter($node->getInternalFileId(), IQueryBuilder::PARAM_INT)));
  271. $result = $query->executeQuery();
  272. $propsByPath = [];
  273. while ($row = $result->fetch()) {
  274. $childPath = $prefix . $row['name'];
  275. if (!isset($propsByPath[$childPath])) {
  276. $propsByPath[$childPath] = [];
  277. }
  278. if (isset($row['propertyname'])) {
  279. $propsByPath[$childPath][$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
  280. }
  281. }
  282. $this->userCache = array_merge($this->userCache, $propsByPath);
  283. }
  284. /**
  285. * Returns a list of properties for the given path and current user
  286. *
  287. * @param string $path
  288. * @param array $requestedProperties requested properties or empty array for "all"
  289. * @return array
  290. * @note The properties list is a list of propertynames the client
  291. * requested, encoded as xmlnamespace#tagName, for example:
  292. * http://www.example.org/namespace#author If the array is empty, all
  293. * properties should be returned
  294. */
  295. private function getUserProperties(string $path, array $requestedProperties) {
  296. if (isset($this->userCache[$path])) {
  297. return $this->userCache[$path];
  298. }
  299. // TODO: chunking if more than 1000 properties
  300. $sql = 'SELECT * FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?';
  301. $whereValues = [$this->user->getUID(), $this->formatPath($path)];
  302. $whereTypes = [null, null];
  303. if (!empty($requestedProperties)) {
  304. // request only a subset
  305. $sql .= ' AND `propertyname` in (?)';
  306. $whereValues[] = $requestedProperties;
  307. $whereTypes[] = \Doctrine\DBAL\Connection::PARAM_STR_ARRAY;
  308. }
  309. $result = $this->connection->executeQuery(
  310. $sql,
  311. $whereValues,
  312. $whereTypes
  313. );
  314. $props = [];
  315. while ($row = $result->fetch()) {
  316. $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
  317. }
  318. $result->closeCursor();
  319. $this->userCache[$path] = $props;
  320. return $props;
  321. }
  322. /**
  323. * @throws Exception
  324. */
  325. private function updateProperties(string $path, array $properties): bool {
  326. // TODO: use "insert or update" strategy ?
  327. $existing = $this->getUserProperties($path, []);
  328. try {
  329. $this->connection->beginTransaction();
  330. foreach ($properties as $propertyName => $propertyValue) {
  331. // common parameters for all queries
  332. $dbParameters = [
  333. 'userid' => $this->user->getUID(),
  334. 'propertyPath' => $this->formatPath($path),
  335. 'propertyName' => $propertyName,
  336. ];
  337. // If it was null, we need to delete the property
  338. if (is_null($propertyValue)) {
  339. if (array_key_exists($propertyName, $existing)) {
  340. $deleteQuery = $deleteQuery ?? $this->createDeleteQuery();
  341. $deleteQuery
  342. ->setParameters($dbParameters)
  343. ->executeStatement();
  344. }
  345. } else {
  346. [$value, $valueType] = $this->encodeValueForDatabase($propertyValue);
  347. $dbParameters['propertyValue'] = $value;
  348. $dbParameters['valueType'] = $valueType;
  349. if (!array_key_exists($propertyName, $existing)) {
  350. $insertQuery = $insertQuery ?? $this->createInsertQuery();
  351. $insertQuery
  352. ->setParameters($dbParameters)
  353. ->executeStatement();
  354. } else {
  355. $updateQuery = $updateQuery ?? $this->createUpdateQuery();
  356. $updateQuery
  357. ->setParameters($dbParameters)
  358. ->executeStatement();
  359. }
  360. }
  361. }
  362. $this->connection->commit();
  363. unset($this->userCache[$path]);
  364. } catch (Exception $e) {
  365. $this->connection->rollBack();
  366. throw $e;
  367. }
  368. return true;
  369. }
  370. /**
  371. * long paths are hashed to ensure they fit in the database
  372. *
  373. * @param string $path
  374. * @return string
  375. */
  376. private function formatPath(string $path): string {
  377. if (strlen($path) > 250) {
  378. return sha1($path);
  379. }
  380. return $path;
  381. }
  382. /**
  383. * @param mixed $value
  384. * @return array
  385. */
  386. private function encodeValueForDatabase($value): array {
  387. if (is_scalar($value)) {
  388. $valueType = self::PROPERTY_TYPE_STRING;
  389. } elseif ($value instanceof Complex) {
  390. $valueType = self::PROPERTY_TYPE_XML;
  391. $value = $value->getXml();
  392. } else {
  393. $valueType = self::PROPERTY_TYPE_OBJECT;
  394. $value = serialize($value);
  395. }
  396. return [$value, $valueType];
  397. }
  398. /**
  399. * @return mixed|Complex|string
  400. */
  401. private function decodeValueFromDatabase(string $value, int $valueType) {
  402. switch ($valueType) {
  403. case self::PROPERTY_TYPE_XML:
  404. return new Complex($value);
  405. case self::PROPERTY_TYPE_OBJECT:
  406. return unserialize($value);
  407. case self::PROPERTY_TYPE_STRING:
  408. default:
  409. return $value;
  410. }
  411. }
  412. private function createDeleteQuery(): IQueryBuilder {
  413. $deleteQuery = $this->connection->getQueryBuilder();
  414. $deleteQuery->delete('properties')
  415. ->where($deleteQuery->expr()->eq('userid', $deleteQuery->createParameter('userid')))
  416. ->andWhere($deleteQuery->expr()->eq('propertypath', $deleteQuery->createParameter('propertyPath')))
  417. ->andWhere($deleteQuery->expr()->eq('propertyname', $deleteQuery->createParameter('propertyName')));
  418. return $deleteQuery;
  419. }
  420. private function createInsertQuery(): IQueryBuilder {
  421. $insertQuery = $this->connection->getQueryBuilder();
  422. $insertQuery->insert('properties')
  423. ->values([
  424. 'userid' => $insertQuery->createParameter('userid'),
  425. 'propertypath' => $insertQuery->createParameter('propertyPath'),
  426. 'propertyname' => $insertQuery->createParameter('propertyName'),
  427. 'propertyvalue' => $insertQuery->createParameter('propertyValue'),
  428. 'valuetype' => $insertQuery->createParameter('valueType'),
  429. ]);
  430. return $insertQuery;
  431. }
  432. private function createUpdateQuery(): IQueryBuilder {
  433. $updateQuery = $this->connection->getQueryBuilder();
  434. $updateQuery->update('properties')
  435. ->set('propertyvalue', $updateQuery->createParameter('propertyValue'))
  436. ->set('valuetype', $updateQuery->createParameter('valueType'))
  437. ->where($updateQuery->expr()->eq('userid', $updateQuery->createParameter('userid')))
  438. ->andWhere($updateQuery->expr()->eq('propertypath', $updateQuery->createParameter('propertyPath')))
  439. ->andWhere($updateQuery->expr()->eq('propertyname', $updateQuery->createParameter('propertyName')));
  440. return $updateQuery;
  441. }
  442. }