Passed
Push — master ( 978047...cd5bd1 )
by Blizzz
16:35 queued 13s
created

SystemAddressbook::isFederation()   B

Complexity

Conditions 7
Paths 5

Size

Total Lines 27
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 1 Features 0
Metric Value
cc 7
eloc 14
c 1
b 1
f 0
nc 5
nop 0
dl 0
loc 27
rs 8.8333
1
<?php
2
3
declare(strict_types=1);
4
5
/**
6
 * @copyright Copyright (c) 2018, Roeland Jago Douma <[email protected]>
7
 *
8
 * @author Joas Schilling <[email protected]>
9
 * @author Julius Härtl <[email protected]>
10
 * @author Roeland Jago Douma <[email protected]>
11
 * @author Anna Larch <[email protected]>
12
 *
13
 * @license GNU AGPL version 3 or any later version
14
 *
15
 * This program is free software: you can redistribute it and/or modify
16
 * it under the terms of the GNU Affero General Public License as
17
 * published by the Free Software Foundation, either version 3 of the
18
 * License, or (at your option) any later version.
19
 *
20
 * This program is distributed in the hope that it will be useful,
21
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
22
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23
 * GNU Affero General Public License for more details.
24
 *
25
 * You should have received a copy of the GNU Affero General Public License
26
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
27
 *
28
 */
29
namespace OCA\DAV\CardDAV;
30
31
use OCA\DAV\Exception\UnsupportedLimitOnInitialSyncException;
32
use OCA\Federation\TrustedServers;
33
use OCP\Accounts\IAccountManager;
34
use OCP\IConfig;
35
use OCP\IGroupManager;
36
use OCP\IL10N;
37
use OCP\IRequest;
38
use OCP\IUserSession;
39
use Sabre\CardDAV\Backend\SyncSupport;
40
use Sabre\CardDAV\Backend\BackendInterface;
41
use Sabre\CardDAV\Card;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, OCA\DAV\CardDAV\Card. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
42
use Sabre\DAV\Exception\Forbidden;
43
use Sabre\DAV\Exception\NotFound;
44
use Sabre\VObject\Component\VCard;
45
use Sabre\VObject\Reader;
46
use function array_filter;
47
use function array_intersect;
48
use function array_unique;
49
use function in_array;
50
51
class SystemAddressbook extends AddressBook {
52
	public const URI_SHARED = 'z-server-generated--system';
53
	/** @var IConfig */
54
	private $config;
55
	private IUserSession $userSession;
56
	private ?TrustedServers $trustedServers;
57
	private ?IRequest $request;
58
	private ?IGroupManager $groupManager;
59
60
	public function __construct(BackendInterface $carddavBackend,
61
		array $addressBookInfo,
62
		IL10N $l10n,
63
		IConfig $config,
64
		IUserSession $userSession,
65
		?IRequest $request = null,
66
		?TrustedServers $trustedServers = null,
67
		?IGroupManager $groupManager) {
68
		parent::__construct($carddavBackend, $addressBookInfo, $l10n);
69
		$this->config = $config;
70
		$this->userSession = $userSession;
71
		$this->request = $request;
72
		$this->trustedServers = $trustedServers;
73
		$this->groupManager = $groupManager;
74
75
		$this->addressBookInfo['{DAV:}displayname'] = $l10n->t('Accounts');
76
		$this->addressBookInfo['{' . Plugin::NS_CARDDAV . '}addressbook-description'] = $l10n->t('System address book which holds all accounts');
77
	}
78
79
	/**
80
	 * No checkbox checked -> Show only the same user
81
	 * 'Allow username autocompletion in share dialog' -> show everyone
82
	 * 'Allow username autocompletion in share dialog' + 'Allow username autocompletion to users within the same groups' -> show only users in intersecting groups
83
	 * 'Allow username autocompletion in share dialog' + 'Allow username autocompletion to users based on phone number integration' -> show only the same user
84
	 * 'Allow username autocompletion in share dialog' + 'Allow username autocompletion to users within the same groups' + 'Allow username autocompletion to users based on phone number integration' -> show only users in intersecting groups
85
	 */
86
	public function getChildren() {
87
		$shareEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
88
		$shareEnumerationGroup = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
89
		$shareEnumerationPhone = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
90
		$user = $this->userSession->getUser();
91
		if (!$user) {
92
			// Should never happen because we don't allow anonymous access
93
			return [];
94
		}
95
		if ($user->getBackendClassName() === 'Guests' || !$shareEnumeration || (!$shareEnumerationGroup && $shareEnumerationPhone)) {
96
			$name = SyncService::getCardUri($user);
97
			try {
98
				return [parent::getChild($name)];
99
			} catch (NotFound $e) {
100
				return [];
101
			}
102
		}
103
		if ($shareEnumerationGroup) {
104
			if ($this->groupManager === null) {
105
				// Group manager is not available, so we can't determine which data is safe
106
				return [];
107
			}
108
			$groups = $this->groupManager->getUserGroups($user);
109
			$names = [];
110
			foreach ($groups as $group) {
111
				$users = $group->getUsers();
112
				foreach ($users as $groupUser) {
113
					if ($groupUser->getBackendClassName() === 'Guests') {
114
						continue;
115
					}
116
					$names[] = SyncService::getCardUri($groupUser);
117
				}
118
			}
119
			return parent::getMultipleChildren(array_unique($names));
120
		}
121
122
		$children = parent::getChildren();
123
		return array_filter($children, function (Card $child) {
124
			// check only for URIs that begin with Guests:
125
			return strpos($child->getName(), 'Guests:') !== 0;
126
		});
127
	}
128
129
	/**
130
	 * @param array $paths
131
	 * @return Card[]
132
	 * @throws NotFound
133
	 */
134
	public function getMultipleChildren($paths): array {
135
		$shareEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
136
		$shareEnumerationGroup = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
137
		$shareEnumerationPhone = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
138
		$user = $this->userSession->getUser();
139
		if (($user !== null && $user->getBackendClassName() === 'Guests') || !$shareEnumeration || (!$shareEnumerationGroup && $shareEnumerationPhone)) {
140
			// No user or cards with no access
141
			if ($user === null || !in_array(SyncService::getCardUri($user), $paths, true)) {
142
				return [];
143
			}
144
			// Only return the own card
145
			try {
146
				return [parent::getChild(SyncService::getCardUri($user))];
147
			} catch (NotFound $e) {
148
				return [];
149
			}
150
		}
151
		if ($shareEnumerationGroup) {
152
			if ($this->groupManager === null || $user === null) {
153
				// Group manager or user is not available, so we can't determine which data is safe
154
				return [];
155
			}
156
			$groups = $this->groupManager->getUserGroups($user);
157
			$allowedNames = [];
158
			foreach ($groups as $group) {
159
				$users = $group->getUsers();
160
				foreach ($users as $groupUser) {
161
					if ($groupUser->getBackendClassName() === 'Guests') {
162
						continue;
163
					}
164
					$allowedNames[] = SyncService::getCardUri($groupUser);
165
				}
166
			}
167
			return parent::getMultipleChildren(array_intersect($paths, $allowedNames));
168
		}
169
		if (!$this->isFederation()) {
170
			return parent::getMultipleChildren($paths);
171
		}
172
173
		$objs = $this->carddavBackend->getMultipleCards($this->addressBookInfo['id'], $paths);
174
		$children = [];
175
		/** @var array $obj */
176
		foreach ($objs as $obj) {
177
			if (empty($obj)) {
178
				continue;
179
			}
180
			$carddata = $this->extractCarddata($obj);
181
			if (empty($carddata)) {
182
				continue;
183
			} else {
184
				$obj['carddata'] = $carddata;
185
			}
186
			$children[] = new Card($this->carddavBackend, $this->addressBookInfo, $obj);
187
		}
188
		return $children;
189
	}
190
191
	/**
192
	 * @param string $name
193
	 * @return Card
194
	 * @throws NotFound
195
	 * @throws Forbidden
196
	 */
197
	public function getChild($name): Card {
198
		$user = $this->userSession->getUser();
199
		$shareEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
200
		$shareEnumerationGroup = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
201
		$shareEnumerationPhone = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
202
		if (($user !== null && $user->getBackendClassName() === 'Guests') || !$shareEnumeration || (!$shareEnumerationGroup && $shareEnumerationPhone)) {
203
			$ownName = $user !== null ? SyncService::getCardUri($user) : null;
204
			if ($ownName === $name) {
205
				return parent::getChild($name);
206
			}
207
			throw new Forbidden();
208
		}
209
		if ($shareEnumerationGroup) {
210
			if ($user === null || $this->groupManager === null) {
211
				// Group manager is not available, so we can't determine which data is safe
212
				throw new Forbidden();
213
			}
214
			$groups = $this->groupManager->getUserGroups($user);
215
			foreach ($groups as $group) {
216
				foreach ($group->getUsers() as $groupUser) {
217
					if ($groupUser->getBackendClassName() === 'Guests') {
218
						continue;
219
					}
220
					$otherName = SyncService::getCardUri($groupUser);
221
					if ($otherName === $name) {
222
						return parent::getChild($name);
223
					}
224
				}
225
			}
226
			throw new Forbidden();
227
		}
228
		if (!$this->isFederation()) {
229
			return parent::getChild($name);
230
		}
231
232
		$obj = $this->carddavBackend->getCard($this->addressBookInfo['id'], $name);
233
		if (!$obj) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $obj of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
234
			throw new NotFound('Card not found');
235
		}
236
		$carddata = $this->extractCarddata($obj);
237
		if (empty($carddata)) {
238
			throw new Forbidden();
239
		} else {
240
			$obj['carddata'] = $carddata;
241
		}
242
		return new Card($this->carddavBackend, $this->addressBookInfo, $obj);
243
	}
244
245
	/**
246
	 * @throws UnsupportedLimitOnInitialSyncException
247
	 */
248
	public function getChanges($syncToken, $syncLevel, $limit = null) {
249
		if (!$syncToken && $limit) {
250
			throw new UnsupportedLimitOnInitialSyncException();
251
		}
252
253
		if (!$this->carddavBackend instanceof SyncSupport) {
0 ignored issues
show
introduced by
$this->carddavBackend is always a sub-type of Sabre\CardDAV\Backend\SyncSupport.
Loading history...
254
			return null;
255
		}
256
257
		if (!$this->isFederation()) {
258
			return parent::getChanges($syncToken, $syncLevel, $limit);
259
		}
260
261
		$changed = $this->carddavBackend->getChangesForAddressBook(
262
			$this->addressBookInfo['id'],
263
			$syncToken,
264
			$syncLevel,
265
			$limit
266
		);
267
268
		if (empty($changed)) {
269
			return $changed;
270
		}
271
272
		$added = $modified = $deleted = [];
273
		foreach ($changed['added'] as $uri) {
274
			try {
275
				$this->getChild($uri);
276
				$added[] = $uri;
277
			} catch (NotFound | Forbidden $e) {
278
				$deleted[] = $uri;
279
			}
280
		}
281
		foreach ($changed['modified'] as $uri) {
282
			try {
283
				$this->getChild($uri);
284
				$modified[] = $uri;
285
			} catch (NotFound | Forbidden $e) {
286
				$deleted[] = $uri;
287
			}
288
		}
289
		$changed['added'] = $added;
290
		$changed['modified'] = $modified;
291
		$changed['deleted'] = $deleted;
292
		return $changed;
293
	}
294
295
	private function isFederation(): bool {
296
		if ($this->trustedServers === null || $this->request === null) {
297
			return false;
298
		}
299
300
		/** @psalm-suppress NoInterfaceProperties */
301
		$server = $this->request->server;
302
		if (!isset($server['PHP_AUTH_USER']) || $server['PHP_AUTH_USER'] !== 'system') {
303
			return false;
304
		}
305
306
		/** @psalm-suppress NoInterfaceProperties */
307
		$sharedSecret = $server['PHP_AUTH_PW'] ?? null;
308
		if ($sharedSecret === null) {
309
			return false;
310
		}
311
312
		$servers = $this->trustedServers->getServers();
313
		$trusted = array_filter($servers, function ($trustedServer) use ($sharedSecret) {
0 ignored issues
show
Bug introduced by
$servers of type OCA\Federation\list is incompatible with the type array expected by parameter $array of array_filter(). ( Ignorable by Annotation )

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

313
		$trusted = array_filter(/** @scrutinizer ignore-type */ $servers, function ($trustedServer) use ($sharedSecret) {
Loading history...
314
			return $trustedServer['shared_secret'] === $sharedSecret;
315
		});
316
		// Authentication is fine, but it's not for a federated share
317
		if (empty($trusted)) {
318
			return false;
319
		}
320
321
		return true;
322
	}
323
324
	/**
325
	 * If the validation doesn't work the card is "not found" so we
326
	 * return empty carddata even if the carddata might exist in the local backend.
327
	 * This can happen when a user sets the required properties
328
	 * FN, N to a local scope only but the request is from
329
	 * a federated share.
330
	 *
331
	 * @see https://github.com/nextcloud/server/issues/38042
332
	 *
333
	 * @param array $obj
334
	 * @return string|null
335
	 */
336
	private function extractCarddata(array $obj): ?string {
337
		$obj['acl'] = $this->getChildACL();
338
		$cardData = $obj['carddata'];
339
		/** @var VCard $vCard */
340
		$vCard = Reader::read($cardData);
341
		foreach ($vCard->children() as $child) {
342
			$scope = $child->offsetGet('X-NC-SCOPE');
343
			if ($scope !== null && $scope->getValue() === IAccountManager::SCOPE_LOCAL) {
344
				$vCard->remove($child);
345
			}
346
		}
347
		$messages = $vCard->validate();
348
		if (!empty($messages)) {
349
			return null;
350
		}
351
352
		return $vCard->serialize();
353
	}
354
355
	/**
356
	 * @return mixed
357
	 * @throws Forbidden
358
	 */
359
	public function delete() {
360
		if ($this->isFederation()) {
361
			parent::delete();
362
		}
363
		throw new Forbidden();
364
	}
365
366
	public function getACL() {
367
		return array_filter(parent::getACL(), function ($acl) {
368
			if (in_array($acl['privilege'], ['{DAV:}write', '{DAV:}all'], true)) {
369
				return false;
370
			}
371
			return true;
372
		});
373
	}
374
}
375