CustomPropertiesBackend.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  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 array[]
  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. if (empty($requestedProps)) {
  162. return;
  163. }
  164. $node = $this->tree->getNodeForPath($path);
  165. if ($node instanceof Directory && $propFind->getDepth() !== 0) {
  166. $this->cacheDirectory($path, $node);
  167. }
  168. // First fetch the published properties (set by another user), then get the ones set by
  169. // the current user. If both are set then the latter as priority.
  170. foreach ($this->getPublishedProperties($path, $requestedProps) as $propName => $propValue) {
  171. $propFind->set($propName, $propValue);
  172. }
  173. foreach ($this->getUserProperties($path, $requestedProps) as $propName => $propValue) {
  174. $propFind->set($propName, $propValue);
  175. }
  176. }
  177. /**
  178. * Updates properties for a path
  179. *
  180. * @param string $path
  181. * @param PropPatch $propPatch
  182. *
  183. * @return void
  184. */
  185. public function propPatch($path, PropPatch $propPatch) {
  186. $propPatch->handleRemaining(function ($changedProps) use ($path) {
  187. return $this->updateProperties($path, $changedProps);
  188. });
  189. }
  190. /**
  191. * This method is called after a node is deleted.
  192. *
  193. * @param string $path path of node for which to delete properties
  194. */
  195. public function delete($path) {
  196. $statement = $this->connection->prepare(
  197. 'DELETE FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?'
  198. );
  199. $statement->execute([$this->user->getUID(), $this->formatPath($path)]);
  200. $statement->closeCursor();
  201. unset($this->userCache[$path]);
  202. }
  203. /**
  204. * This method is called after a successful MOVE
  205. *
  206. * @param string $source
  207. * @param string $destination
  208. *
  209. * @return void
  210. */
  211. public function move($source, $destination) {
  212. $statement = $this->connection->prepare(
  213. 'UPDATE `*PREFIX*properties` SET `propertypath` = ?' .
  214. ' WHERE `userid` = ? AND `propertypath` = ?'
  215. );
  216. $statement->execute([$this->formatPath($destination), $this->user->getUID(), $this->formatPath($source)]);
  217. $statement->closeCursor();
  218. }
  219. /**
  220. * @param string $path
  221. * @param string[] $requestedProperties
  222. *
  223. * @return array
  224. */
  225. private function getPublishedProperties(string $path, array $requestedProperties): array {
  226. $allowedProps = array_intersect(self::PUBLISHED_READ_ONLY_PROPERTIES, $requestedProperties);
  227. if (empty($allowedProps)) {
  228. return [];
  229. }
  230. $qb = $this->connection->getQueryBuilder();
  231. $qb->select('*')
  232. ->from(self::TABLE_NAME)
  233. ->where($qb->expr()->eq('propertypath', $qb->createNamedParameter($path)));
  234. $result = $qb->executeQuery();
  235. $props = [];
  236. while ($row = $result->fetch()) {
  237. $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
  238. }
  239. $result->closeCursor();
  240. return $props;
  241. }
  242. /**
  243. * prefetch all user properties in a directory
  244. */
  245. private function cacheDirectory(string $path, Directory $node): void {
  246. $prefix = ltrim($path . '/', '/');
  247. $query = $this->connection->getQueryBuilder();
  248. $query->select('name', 'propertypath', 'propertyname', 'propertyvalue', 'valuetype')
  249. ->from('filecache', 'f')
  250. ->leftJoin('f', 'properties', 'p', $query->expr()->andX(
  251. $query->expr()->eq('propertypath', $query->func()->concat(
  252. $query->createNamedParameter($prefix),
  253. 'name'
  254. )),
  255. $query->expr()->eq('userid', $query->createNamedParameter($this->user->getUID()))
  256. ))
  257. ->where($query->expr()->eq('parent', $query->createNamedParameter($node->getInternalFileId(), IQueryBuilder::PARAM_INT)));
  258. $result = $query->executeQuery();
  259. $propsByPath = [];
  260. while ($row = $result->fetch()) {
  261. $childPath = $prefix . $row['name'];
  262. if (!isset($propsByPath[$childPath])) {
  263. $propsByPath[$childPath] = [];
  264. }
  265. if (isset($row['propertyname'])) {
  266. $propsByPath[$childPath][$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
  267. }
  268. }
  269. $this->userCache = array_merge($this->userCache, $propsByPath);
  270. }
  271. /**
  272. * Returns a list of properties for the given path and current user
  273. *
  274. * @param string $path
  275. * @param array $requestedProperties requested properties or empty array for "all"
  276. * @return array
  277. * @note The properties list is a list of propertynames the client
  278. * requested, encoded as xmlnamespace#tagName, for example:
  279. * http://www.example.org/namespace#author If the array is empty, all
  280. * properties should be returned
  281. */
  282. private function getUserProperties(string $path, array $requestedProperties) {
  283. if (isset($this->userCache[$path])) {
  284. return $this->userCache[$path];
  285. }
  286. // TODO: chunking if more than 1000 properties
  287. $sql = 'SELECT * FROM `*PREFIX*properties` WHERE `userid` = ? AND `propertypath` = ?';
  288. $whereValues = [$this->user->getUID(), $this->formatPath($path)];
  289. $whereTypes = [null, null];
  290. if (!empty($requestedProperties)) {
  291. // request only a subset
  292. $sql .= ' AND `propertyname` in (?)';
  293. $whereValues[] = $requestedProperties;
  294. $whereTypes[] = \Doctrine\DBAL\Connection::PARAM_STR_ARRAY;
  295. }
  296. $result = $this->connection->executeQuery(
  297. $sql,
  298. $whereValues,
  299. $whereTypes
  300. );
  301. $props = [];
  302. while ($row = $result->fetch()) {
  303. $props[$row['propertyname']] = $this->decodeValueFromDatabase($row['propertyvalue'], $row['valuetype']);
  304. }
  305. $result->closeCursor();
  306. $this->userCache[$path] = $props;
  307. return $props;
  308. }
  309. /**
  310. * @throws Exception
  311. */
  312. private function updateProperties(string $path, array $properties): bool {
  313. // TODO: use "insert or update" strategy ?
  314. $existing = $this->getUserProperties($path, []);
  315. try {
  316. $this->connection->beginTransaction();
  317. foreach ($properties as $propertyName => $propertyValue) {
  318. // common parameters for all queries
  319. $dbParameters = [
  320. 'userid' => $this->user->getUID(),
  321. 'propertyPath' => $this->formatPath($path),
  322. 'propertyName' => $propertyName,
  323. ];
  324. // If it was null, we need to delete the property
  325. if (is_null($propertyValue)) {
  326. if (array_key_exists($propertyName, $existing)) {
  327. $deleteQuery = $deleteQuery ?? $this->createDeleteQuery();
  328. $deleteQuery
  329. ->setParameters($dbParameters)
  330. ->executeStatement();
  331. }
  332. } else {
  333. [$value, $valueType] = $this->encodeValueForDatabase($propertyValue);
  334. $dbParameters['propertyValue'] = $value;
  335. $dbParameters['valueType'] = $valueType;
  336. if (!array_key_exists($propertyName, $existing)) {
  337. $insertQuery = $insertQuery ?? $this->createInsertQuery();
  338. $insertQuery
  339. ->setParameters($dbParameters)
  340. ->executeStatement();
  341. } else {
  342. $updateQuery = $updateQuery ?? $this->createUpdateQuery();
  343. $updateQuery
  344. ->setParameters($dbParameters)
  345. ->executeStatement();
  346. }
  347. }
  348. }
  349. $this->connection->commit();
  350. unset($this->userCache[$path]);
  351. } catch (Exception $e) {
  352. $this->connection->rollBack();
  353. throw $e;
  354. }
  355. return true;
  356. }
  357. /**
  358. * long paths are hashed to ensure they fit in the database
  359. *
  360. * @param string $path
  361. * @return string
  362. */
  363. private function formatPath(string $path): string {
  364. if (strlen($path) > 250) {
  365. return sha1($path);
  366. }
  367. return $path;
  368. }
  369. /**
  370. * @param mixed $value
  371. * @return array
  372. */
  373. private function encodeValueForDatabase($value): array {
  374. if (is_scalar($value)) {
  375. $valueType = self::PROPERTY_TYPE_STRING;
  376. } elseif ($value instanceof Complex) {
  377. $valueType = self::PROPERTY_TYPE_XML;
  378. $value = $value->getXml();
  379. } else {
  380. $valueType = self::PROPERTY_TYPE_OBJECT;
  381. $value = serialize($value);
  382. }
  383. return [$value, $valueType];
  384. }
  385. /**
  386. * @return mixed|Complex|string
  387. */
  388. private function decodeValueFromDatabase(string $value, int $valueType) {
  389. switch ($valueType) {
  390. case self::PROPERTY_TYPE_XML:
  391. return new Complex($value);
  392. case self::PROPERTY_TYPE_OBJECT:
  393. return unserialize($value);
  394. case self::PROPERTY_TYPE_STRING:
  395. default:
  396. return $value;
  397. }
  398. }
  399. private function createDeleteQuery(): IQueryBuilder {
  400. $deleteQuery = $this->connection->getQueryBuilder();
  401. $deleteQuery->delete('properties')
  402. ->where($deleteQuery->expr()->eq('userid', $deleteQuery->createParameter('userid')))
  403. ->andWhere($deleteQuery->expr()->eq('propertypath', $deleteQuery->createParameter('propertyPath')))
  404. ->andWhere($deleteQuery->expr()->eq('propertyname', $deleteQuery->createParameter('propertyName')));
  405. return $deleteQuery;
  406. }
  407. private function createInsertQuery(): IQueryBuilder {
  408. $insertQuery = $this->connection->getQueryBuilder();
  409. $insertQuery->insert('properties')
  410. ->values([
  411. 'userid' => $insertQuery->createParameter('userid'),
  412. 'propertypath' => $insertQuery->createParameter('propertyPath'),
  413. 'propertyname' => $insertQuery->createParameter('propertyName'),
  414. 'propertyvalue' => $insertQuery->createParameter('propertyValue'),
  415. 'valuetype' => $insertQuery->createParameter('valueType'),
  416. ]);
  417. return $insertQuery;
  418. }
  419. private function createUpdateQuery(): IQueryBuilder {
  420. $updateQuery = $this->connection->getQueryBuilder();
  421. $updateQuery->update('properties')
  422. ->set('propertyvalue', $updateQuery->createParameter('propertyValue'))
  423. ->set('valuetype', $updateQuery->createParameter('valueType'))
  424. ->where($updateQuery->expr()->eq('userid', $updateQuery->createParameter('userid')))
  425. ->andWhere($updateQuery->expr()->eq('propertypath', $updateQuery->createParameter('propertyPath')))
  426. ->andWhere($updateQuery->expr()->eq('propertyname', $updateQuery->createParameter('propertyName')));
  427. return $updateQuery;
  428. }
  429. }