Passed
Push — master ( 7b0e11...1d7207 )
by Morris
28:08 queued 15s
created

searchPrincipalsByMetadataKey()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 21
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 14
nc 3
nop 2
dl 0
loc 21
rs 9.7998
c 0
b 0
f 0
1
<?php
2
/**
3
 * @copyright 2019, Georg Ehrke <[email protected]>
4
 *
5
 * @author Georg Ehrke <[email protected]>
6
 *
7
 * @license GNU AGPL version 3 or any later version
8
 *
9
 * This program is free software: you can redistribute it and/or modify
10
 * it under the terms of the GNU Affero General Public License as
11
 * published by the Free Software Foundation, either version 3 of the
12
 * License, or (at your option) any later version.
13
 *
14
 * This program is distributed in the hope that it will be useful,
15
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17
 * GNU Affero General Public License for more details.
18
 *
19
 * You should have received a copy of the GNU Affero General Public License
20
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
21
 *
22
 */
23
namespace OCA\DAV\CalDAV\ResourceBooking;
24
25
use OCP\IDBConnection;
26
use OCP\IGroupManager;
27
use OCP\ILogger;
28
use OCP\IUserSession;
29
use Sabre\DAVACL\PrincipalBackend\BackendInterface;
30
use Sabre\DAV\Exception;
31
use \Sabre\DAV\PropPatch;
32
33
abstract class AbstractPrincipalBackend implements BackendInterface {
34
35
	/** @var IDBConnection */
36
	private $db;
37
38
	/** @var IUserSession */
39
	private $userSession;
40
41
	/** @var IGroupManager */
42
	private $groupManager;
43
44
	/** @var ILogger */
45
	private $logger;
46
47
	/** @var string */
48
	private $principalPrefix;
49
50
	/** @var string */
51
	private $dbTableName;
52
53
	/** @var string */
54
	private $dbMetaDataTableName;
55
56
	/** @var string */
57
	private $dbForeignKeyName;
58
59
	/** @var string */
60
	private $cuType;
61
62
	/**
63
	 * @param IDBConnection $dbConnection
64
	 * @param IUserSession $userSession
65
	 * @param IGroupManager $groupManager
66
	 * @param ILogger $logger
67
	 * @param string $principalPrefix
68
	 * @param string $dbPrefix
69
	 * @param string $cuType
70
	 */
71
	public function __construct(IDBConnection $dbConnection,
72
								IUserSession $userSession,
73
								IGroupManager $groupManager,
74
								ILogger $logger,
75
								string $principalPrefix,
76
								string $dbPrefix,
77
								string $cuType) {
78
		$this->db = $dbConnection;
79
		$this->userSession = $userSession;
80
		$this->groupManager = $groupManager;
81
		$this->logger = $logger;
82
		$this->principalPrefix = $principalPrefix;
83
		$this->dbTableName = 'calendar_' . $dbPrefix . 's';
84
		$this->dbMetaDataTableName = $this->dbTableName . '_md';
85
		$this->dbForeignKeyName = $dbPrefix . '_id';
86
		$this->cuType = $cuType;
87
	}
88
89
	/**
90
	 * Returns a list of principals based on a prefix.
91
	 *
92
	 * This prefix will often contain something like 'principals'. You are only
93
	 * expected to return principals that are in this base path.
94
	 *
95
	 * You are expected to return at least a 'uri' for every user, you can
96
	 * return any additional properties if you wish so. Common properties are:
97
	 *   {DAV:}displayname
98
	 *
99
	 * @param string $prefixPath
100
	 * @return string[]
101
	 */
102
	public function getPrincipalsByPrefix($prefixPath) {
103
		$principals = [];
104
105
		if ($prefixPath === $this->principalPrefix) {
106
			$query = $this->db->getQueryBuilder();
107
			$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
108
				->from($this->dbTableName);
109
			$stmt = $query->execute();
110
111
			$metaDataQuery = $this->db->getQueryBuilder();
112
			$metaDataQuery->select([$this->dbForeignKeyName, 'key', 'value'])
113
				->from($this->dbMetaDataTableName);
114
			$metaDataStmt = $metaDataQuery->execute();
115
			$metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC);
116
117
			$metaDataById = [];
118
			foreach($metaDataRows as $metaDataRow) {
119
				if (!isset($metaDataById[$metaDataRow[$this->dbForeignKeyName]])) {
120
					$metaDataById[$metaDataRow[$this->dbForeignKeyName]] = [];
121
				}
122
123
				$metaDataById[$metaDataRow[$this->dbForeignKeyName]][$metaDataRow['key']] =
124
					$metaDataRow['value'];
125
			}
126
127
			while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
128
				$id = $row['id'];
129
130
				if (isset($metaDataById[$id])) {
131
					$principals[] = $this->rowToPrincipal($row, $metaDataById[$id]);
132
				} else {
133
					$principals[] = $this->rowToPrincipal($row);
134
				}
135
136
			}
137
138
			$stmt->closeCursor();
139
		}
140
141
		return $principals;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $principals returns an array which contains values of type array which are incompatible with the documented value type string.
Loading history...
142
	}
143
144
	/**
145
	 * Returns a specific principal, specified by it's path.
146
	 * The returned structure should be the exact same as from
147
	 * getPrincipalsByPrefix.
148
	 *
149
	 * @param string $path
150
	 * @return array
151
	 */
152
	public function getPrincipalByPath($path) {
153
		if (strpos($path, $this->principalPrefix) !== 0) {
154
			return null;
155
		}
156
		list(, $name) = \Sabre\Uri\split($path);
157
158
		list($backendId, $resourceId) = explode('-',  $name, 2);
159
160
		$query = $this->db->getQueryBuilder();
161
		$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
162
			->from($this->dbTableName)
163
			->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)))
164
			->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId)));
165
		$stmt = $query->execute();
166
		$row = $stmt->fetch(\PDO::FETCH_ASSOC);
167
168
		if(!$row) {
169
			return null;
170
		}
171
172
		$metaDataQuery = $this->db->getQueryBuilder();
173
		$metaDataQuery->select(['key', 'value'])
174
			->from($this->dbMetaDataTableName)
175
			->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id'])));
176
		$metaDataStmt = $metaDataQuery->execute();
177
		$metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC);
178
		$metadata = [];
179
180
		foreach($metaDataRows as $metaDataRow) {
181
			$metadata[$metaDataRow['key']] = $metaDataRow['value'];
182
		}
183
184
		return $this->rowToPrincipal($row, $metadata);
185
	}
186
187
	/**
188
	 * @param int $id
189
	 * @return array|null
190
	 */
191
	public function getPrincipalById($id):?array {
192
		$query = $this->db->getQueryBuilder();
193
		$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname'])
194
			->from($this->dbTableName)
195
			->where($query->expr()->eq('id', $query->createNamedParameter($id)));
196
		$stmt = $query->execute();
197
		$row = $stmt->fetch(\PDO::FETCH_ASSOC);
198
199
		if(!$row) {
200
			return null;
201
		}
202
203
		$metaDataQuery = $this->db->getQueryBuilder();
204
		$metaDataQuery->select(['key', 'value'])
205
			->from($this->dbMetaDataTableName)
206
			->where($metaDataQuery->expr()->eq($this->dbForeignKeyName, $metaDataQuery->createNamedParameter($row['id'])));
207
		$metaDataStmt = $metaDataQuery->execute();
208
		$metaDataRows = $metaDataStmt->fetchAll(\PDO::FETCH_ASSOC);
209
		$metadata = [];
210
211
		foreach($metaDataRows as $metaDataRow) {
212
			$metadata[$metaDataRow['key']] = $metaDataRow['value'];
213
		}
214
215
		return $this->rowToPrincipal($row, $metadata);
216
	}
217
218
	/**
219
	 * Returns the list of members for a group-principal
220
	 *
221
	 * @param string $principal
222
	 * @return string[]
223
	 */
224
	public function getGroupMemberSet($principal) {
225
		return [];
226
	}
227
228
	/**
229
	 * Returns the list of groups a principal is a member of
230
	 *
231
	 * @param string $principal
232
	 * @return array
233
	 */
234
	public function getGroupMembership($principal) {
235
		return [];
236
	}
237
238
	/**
239
	 * Updates the list of group members for a group principal.
240
	 *
241
	 * The principals should be passed as a list of uri's.
242
	 *
243
	 * @param string $principal
244
	 * @param string[] $members
245
	 * @throws Exception
246
	 */
247
	public function setGroupMemberSet($principal, array $members) {
248
		throw new Exception('Setting members of the group is not supported yet');
249
	}
250
251
	/**
252
	 * @param string $path
253
	 * @param PropPatch $propPatch
254
	 * @return int
255
	 */
256
	function updatePrincipal($path, PropPatch $propPatch) {
0 ignored issues
show
Best Practice introduced by
It is generally recommended to explicitly declare the visibility for methods.

Adding explicit visibility (private, protected, or public) is generally recommend to communicate to other developers how, and from where this method is intended to be used.

Loading history...
257
		return 0;
0 ignored issues
show
Bug Best Practice introduced by
The expression return 0 returns the type integer which is incompatible with the return type mandated by Sabre\DAVACL\PrincipalBa...face::updatePrincipal() of void.

In the issue above, the returned value is violating the contract defined by the mentioned interface.

Let's take a look at an example:

interface HasName {
    /** @return string */
    public function getName();
}

class Name {
    public $name;
}

class User implements HasName {
    /** @return string|Name */
    public function getName() {
        return new Name('foo'); // This is a violation of the ``HasName`` interface
                                // which only allows a string value to be returned.
    }
}
Loading history...
258
	}
259
260
	/**
261
	 * @param string $prefixPath
262
	 * @param array $searchProperties
263
	 * @param string $test
264
	 * @return array
265
	 */
266
	function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') {
0 ignored issues
show
Best Practice introduced by
It is generally recommended to explicitly declare the visibility for methods.

Adding explicit visibility (private, protected, or public) is generally recommend to communicate to other developers how, and from where this method is intended to be used.

Loading history...
267
		$results = [];
268
		if (\count($searchProperties) === 0) {
269
			return [];
270
		}
271
		if ($prefixPath !== $this->principalPrefix) {
272
			return [];
273
		}
274
275
		$user = $this->userSession->getUser();
276
		if (!$user) {
277
			return [];
278
		}
279
		$usersGroups = $this->groupManager->getUserGroupIds($user);
280
281
		foreach ($searchProperties as $prop => $value) {
282
			switch ($prop) {
283
				case '{http://sabredav.org/ns}email-address':
284
					$query = $this->db->getQueryBuilder();
285
					$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
286
						->from($this->dbTableName)
287
						->where($query->expr()->iLike('email', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
288
289
					$stmt = $query->execute();
290
					$principals = [];
291
					while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
292
						if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
293
							continue;
294
						}
295
						$principals[] = $this->rowToPrincipal($row)['uri'];
296
					}
297
					$results[] = $principals;
298
299
					$stmt->closeCursor();
300
					break;
301
302
				case '{DAV:}displayname':
303
					$query = $this->db->getQueryBuilder();
304
					$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
305
						->from($this->dbTableName)
306
						->where($query->expr()->iLike('displayname', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
307
308
					$stmt = $query->execute();
309
					$principals = [];
310
					while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
311
						if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
312
							continue;
313
						}
314
						$principals[] = $this->rowToPrincipal($row)['uri'];
315
					}
316
					$results[] = $principals;
317
318
					$stmt->closeCursor();
319
					break;
320
321
				case '{urn:ietf:params:xml:ns:caldav}calendar-user-address-set':
322
					// If you add support for more search properties that qualify as a user-address,
323
					// please also add them to the array below
324
					$results[] = $this->searchPrincipals($this->principalPrefix, [
325
						'{http://sabredav.org/ns}email-address' => $value,
326
					], 'anyof');
327
					break;
328
329
				default:
330
					$rowsByMetadata = $this->searchPrincipalsByMetadataKey($prop, $value);
331
					$filteredRows = array_filter($rowsByMetadata, function($row) use ($usersGroups) {
332
						return $this->isAllowedToAccessResource($row, $usersGroups);
333
					});
334
335
					$results[] = array_map(function($row) {
336
						return $row['uri'];
337
					}, $filteredRows);
338
339
					break;
340
			}
341
		}
342
343
		// results is an array of arrays, so this is not the first search result
344
		// but the results of the first searchProperty
345
		if (count($results) === 1) {
346
			return $results[0];
347
		}
348
349
		switch ($test) {
350
			case 'anyof':
351
				return array_values(array_unique(array_merge(...$results)));
352
353
			case 'allof':
354
			default:
355
				return array_values(array_intersect(...$results));
0 ignored issues
show
Bug introduced by
The call to array_intersect() has too few arguments starting with array2. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

355
				return array_values(/** @scrutinizer ignore-call */ array_intersect(...$results));

This check compares calls to functions or methods with their respective definitions. If the call has less arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
356
		}
357
	}
358
359
	/**
360
	 * Searches principals based on their metadata keys.
361
	 * This allows to search for all principals with a specific key.
362
	 * e.g.:
363
	 * '{http://nextcloud.com/ns}room-building-address' => 'ABC Street 123, ...'
364
	 *
365
	 * @param $key
366
	 * @param $value
367
	 * @return array
368
	 */
369
	private function searchPrincipalsByMetadataKey($key, $value):array {
370
		$query = $this->db->getQueryBuilder();
371
		$query->select([$this->dbForeignKeyName])
372
			->from($this->dbMetaDataTableName)
373
			->where($query->expr()->eq('key', $query->createNamedParameter($key)))
374
			->andWhere($query->expr()->iLike('value', $query->createNamedParameter('%' . $this->db->escapeLikeParameter($value) . '%')));
375
		$stmt = $query->execute();
376
377
		$rows = [];
378
		while($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
379
			$id = $row[$this->dbForeignKeyName];
380
381
			$principalRow = $this->getPrincipalById($id);
382
			if (!$principalRow) {
383
				continue;
384
			}
385
386
			$rows[] = $principalRow;
387
		}
388
389
		return $rows;
390
	}
391
392
	/**
393
	 * @param string $uri
394
	 * @param string $principalPrefix
395
	 * @return null|string
396
	 */
397
	function findByUri($uri, $principalPrefix) {
0 ignored issues
show
Best Practice introduced by
It is generally recommended to explicitly declare the visibility for methods.

Adding explicit visibility (private, protected, or public) is generally recommend to communicate to other developers how, and from where this method is intended to be used.

Loading history...
398
		$user = $this->userSession->getUser();
399
		if (!$user) {
400
			return null;
401
		}
402
		$usersGroups = $this->groupManager->getUserGroupIds($user);
403
404
		if (strpos($uri, 'mailto:') === 0) {
405
			$email = substr($uri, 7);
406
			$query = $this->db->getQueryBuilder();
407
			$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
408
				->from($this->dbTableName)
409
				->where($query->expr()->eq('email', $query->createNamedParameter($email)));
410
411
			$stmt = $query->execute();
412
			$row = $stmt->fetch(\PDO::FETCH_ASSOC);
413
414
			if(!$row) {
415
				return null;
416
			}
417
			if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
418
				return null;
419
			}
420
421
			return $this->rowToPrincipal($row)['uri'];
422
		}
423
424
		if (strpos($uri, 'principal:') === 0) {
425
			$path = substr($uri, 10);
426
			if (strpos($path, $this->principalPrefix) !== 0) {
427
				return null;
428
			}
429
430
			list(, $name) = \Sabre\Uri\split($path);
431
			list($backendId, $resourceId) = explode('-',  $name, 2);
432
433
			$query = $this->db->getQueryBuilder();
434
			$query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions'])
435
				->from($this->dbTableName)
436
				->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId)))
437
				->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId)));
438
			$stmt = $query->execute();
439
			$row = $stmt->fetch(\PDO::FETCH_ASSOC);
440
441
			if(!$row) {
442
				return null;
443
			}
444
			if (!$this->isAllowedToAccessResource($row, $usersGroups)) {
445
				return null;
446
			}
447
448
			return $this->rowToPrincipal($row)['uri'];
449
		}
450
451
		return null;
452
	}
453
454
	/**
455
	 * convert database row to principal
456
	 *
457
	 * @param String[] $row
458
	 * @param String[] $metadata
459
	 * @return Array
460
	 */
461
	private function rowToPrincipal(array $row, array $metadata=[]):array {
462
		return array_merge([
463
			'uri' => $this->principalPrefix . '/' . $row['backend_id'] . '-' . $row['resource_id'],
464
			'{DAV:}displayname' => $row['displayname'],
465
			'{http://sabredav.org/ns}email-address' => $row['email'],
466
			'{urn:ietf:params:xml:ns:caldav}calendar-user-type' => $this->cuType,
467
		], $metadata);
468
	}
469
470
	/**
471
	 * @param $row
472
	 * @param $userGroups
473
	 * @return bool
474
	 */
475
	private function isAllowedToAccessResource(array $row, array $userGroups):bool {
476
		if (!isset($row['group_restrictions']) ||
477
			$row['group_restrictions'] === null ||
478
			$row['group_restrictions'] === '') {
479
			return true;
480
		}
481
482
		// group restrictions contains something, but not parsable, deny access and log warning
483
		$json = json_decode($row['group_restrictions']);
484
		if (!\is_array($json)) {
485
			$this->logger->info('group_restrictions field could not be parsed for ' . $this->dbTableName . '::' . $row['id'] . ', denying access to resource');
486
			return false;
487
		}
488
489
		// empty array => no group restrictions
490
		if (empty($json)) {
491
			return true;
492
		}
493
494
		return !empty(array_intersect($json, $userGroups));
495
	}
496
}
497