CustomPropertiesBackend.php 11 KB

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