Passed
Push — master ( 6cf174...2d1d93 )
by Christoph
22:01 queued 11s
created

CustomPropertiesBackend::getUserProperties()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 33
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

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