1
0

CustomPropertiesBackend.php 15 KB

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