CustomPropertiesBackend.php 13 KB

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