Principal.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
  5. * SPDX-License-Identifier: AGPL-3.0-only
  6. */
  7. namespace OCA\DAV\Connector\Sabre;
  8. use OC\KnownUser\KnownUserService;
  9. use OCA\Circles\Exceptions\CircleNotFoundException;
  10. use OCA\DAV\CalDAV\Proxy\ProxyMapper;
  11. use OCA\DAV\Traits\PrincipalProxyTrait;
  12. use OCP\Accounts\IAccountManager;
  13. use OCP\Accounts\IAccountProperty;
  14. use OCP\Accounts\PropertyDoesNotExistException;
  15. use OCP\App\IAppManager;
  16. use OCP\AppFramework\QueryException;
  17. use OCP\Constants;
  18. use OCP\IConfig;
  19. use OCP\IGroup;
  20. use OCP\IGroupManager;
  21. use OCP\IUser;
  22. use OCP\IUserManager;
  23. use OCP\IUserSession;
  24. use OCP\L10N\IFactory;
  25. use OCP\Share\IManager as IShareManager;
  26. use Sabre\DAV\Exception;
  27. use Sabre\DAV\PropPatch;
  28. use Sabre\DAVACL\PrincipalBackend\BackendInterface;
  29. class Principal implements BackendInterface {
  30. /** @var IUserManager */
  31. private $userManager;
  32. /** @var IGroupManager */
  33. private $groupManager;
  34. /** @var IAccountManager */
  35. private $accountManager;
  36. /** @var IShareManager */
  37. private $shareManager;
  38. /** @var IUserSession */
  39. private $userSession;
  40. /** @var IAppManager */
  41. private $appManager;
  42. /** @var string */
  43. private $principalPrefix;
  44. /** @var bool */
  45. private $hasGroups;
  46. /** @var bool */
  47. private $hasCircles;
  48. /** @var ProxyMapper */
  49. private $proxyMapper;
  50. /** @var KnownUserService */
  51. private $knownUserService;
  52. /** @var IConfig */
  53. private $config;
  54. /** @var IFactory */
  55. private $languageFactory;
  56. public function __construct(IUserManager $userManager,
  57. IGroupManager $groupManager,
  58. IAccountManager $accountManager,
  59. IShareManager $shareManager,
  60. IUserSession $userSession,
  61. IAppManager $appManager,
  62. ProxyMapper $proxyMapper,
  63. KnownUserService $knownUserService,
  64. IConfig $config,
  65. IFactory $languageFactory,
  66. string $principalPrefix = 'principals/users/') {
  67. $this->userManager = $userManager;
  68. $this->groupManager = $groupManager;
  69. $this->accountManager = $accountManager;
  70. $this->shareManager = $shareManager;
  71. $this->userSession = $userSession;
  72. $this->appManager = $appManager;
  73. $this->principalPrefix = trim($principalPrefix, '/');
  74. $this->hasGroups = $this->hasCircles = ($principalPrefix === 'principals/users/');
  75. $this->proxyMapper = $proxyMapper;
  76. $this->knownUserService = $knownUserService;
  77. $this->config = $config;
  78. $this->languageFactory = $languageFactory;
  79. }
  80. use PrincipalProxyTrait {
  81. getGroupMembership as protected traitGetGroupMembership;
  82. }
  83. /**
  84. * Returns a list of principals based on a prefix.
  85. *
  86. * This prefix will often contain something like 'principals'. You are only
  87. * expected to return principals that are in this base path.
  88. *
  89. * You are expected to return at least a 'uri' for every user, you can
  90. * return any additional properties if you wish so. Common properties are:
  91. * {DAV:}displayname
  92. *
  93. * @param string $prefixPath
  94. * @return string[]
  95. */
  96. public function getPrincipalsByPrefix($prefixPath) {
  97. $principals = [];
  98. if ($prefixPath === $this->principalPrefix) {
  99. foreach ($this->userManager->search('') as $user) {
  100. $principals[] = $this->userToPrincipal($user);
  101. }
  102. }
  103. return $principals;
  104. }
  105. /**
  106. * Returns a specific principal, specified by it's path.
  107. * The returned structure should be the exact same as from
  108. * getPrincipalsByPrefix.
  109. *
  110. * @param string $path
  111. * @return array
  112. */
  113. public function getPrincipalByPath($path) {
  114. [$prefix, $name] = \Sabre\Uri\split($path);
  115. $decodedName = urldecode($name);
  116. if ($name === 'calendar-proxy-write' || $name === 'calendar-proxy-read') {
  117. [$prefix2, $name2] = \Sabre\Uri\split($prefix);
  118. if ($prefix2 === $this->principalPrefix) {
  119. $user = $this->userManager->get($name2);
  120. if ($user !== null) {
  121. return [
  122. 'uri' => 'principals/users/' . $user->getUID() . '/' . $name,
  123. ];
  124. }
  125. return null;
  126. }
  127. }
  128. if ($prefix === $this->principalPrefix) {
  129. // Depending on where it is called, it may happen that this function
  130. // is called either with a urlencoded version of the name or with a non-urlencoded one.
  131. // The urldecode function replaces %## and +, both of which are forbidden in usernames.
  132. // Hence there can be no ambiguity here and it is safe to call urldecode on all usernames
  133. $user = $this->userManager->get($decodedName);
  134. if ($user !== null) {
  135. return $this->userToPrincipal($user);
  136. }
  137. } elseif ($prefix === 'principals/circles') {
  138. if ($this->userSession->getUser() !== null) {
  139. // At the time of writing - 2021-01-19 — a mixed state is possible.
  140. // The second condition can be removed when this is fixed.
  141. return $this->circleToPrincipal($decodedName)
  142. ?: $this->circleToPrincipal($name);
  143. }
  144. } elseif ($prefix === 'principals/groups') {
  145. // At the time of writing - 2021-01-19 — a mixed state is possible.
  146. // The second condition can be removed when this is fixed.
  147. $group = $this->groupManager->get($decodedName)
  148. ?: $this->groupManager->get($name);
  149. if ($group instanceof IGroup) {
  150. return [
  151. 'uri' => 'principals/groups/' . $name,
  152. '{DAV:}displayname' => $group->getDisplayName(),
  153. ];
  154. }
  155. } elseif ($prefix === 'principals/system') {
  156. return [
  157. 'uri' => 'principals/system/' . $name,
  158. '{DAV:}displayname' => $this->languageFactory->get('dav')->t('Accounts'),
  159. ];
  160. }
  161. return null;
  162. }
  163. /**
  164. * Returns the list of groups a principal is a member of
  165. *
  166. * @param string $principal
  167. * @param bool $needGroups
  168. * @return array
  169. * @throws Exception
  170. */
  171. public function getGroupMembership($principal, $needGroups = false) {
  172. [$prefix, $name] = \Sabre\Uri\split($principal);
  173. if ($prefix !== $this->principalPrefix) {
  174. return [];
  175. }
  176. $user = $this->userManager->get($name);
  177. if (!$user) {
  178. throw new Exception('Principal not found');
  179. }
  180. $groups = [];
  181. if ($this->hasGroups || $needGroups) {
  182. $userGroups = $this->groupManager->getUserGroups($user);
  183. foreach ($userGroups as $userGroup) {
  184. $groups[] = 'principals/groups/' . urlencode($userGroup->getGID());
  185. }
  186. }
  187. $groups = array_unique(array_merge(
  188. $groups,
  189. $this->traitGetGroupMembership($principal, $needGroups)
  190. ));
  191. return $groups;
  192. }
  193. /**
  194. * @param string $path
  195. * @param PropPatch $propPatch
  196. * @return int
  197. */
  198. public function updatePrincipal($path, PropPatch $propPatch) {
  199. // Updating schedule-default-calendar-URL is handled in CustomPropertiesBackend
  200. return 0;
  201. }
  202. /**
  203. * Search user principals
  204. *
  205. * @param array $searchProperties
  206. * @param string $test
  207. * @return array
  208. */
  209. protected function searchUserPrincipals(array $searchProperties, $test = 'allof') {
  210. $results = [];
  211. // If sharing is disabled, return the empty array
  212. $shareAPIEnabled = $this->shareManager->shareApiEnabled();
  213. if (!$shareAPIEnabled) {
  214. return [];
  215. }
  216. $allowEnumeration = $this->shareManager->allowEnumeration();
  217. $limitEnumerationGroup = $this->shareManager->limitEnumerationToGroups();
  218. $limitEnumerationPhone = $this->shareManager->limitEnumerationToPhone();
  219. $allowEnumerationFullMatch = $this->shareManager->allowEnumerationFullMatch();
  220. $ignoreSecondDisplayName = $this->shareManager->ignoreSecondDisplayName();
  221. $matchEmail = $this->shareManager->matchEmail();
  222. // If sharing is restricted to group members only,
  223. // return only members that have groups in common
  224. $restrictGroups = false;
  225. $currentUser = $this->userSession->getUser();
  226. if ($this->shareManager->shareWithGroupMembersOnly()) {
  227. if (!$currentUser instanceof IUser) {
  228. return [];
  229. }
  230. $restrictGroups = $this->groupManager->getUserGroupIds($currentUser);
  231. }
  232. $currentUserGroups = [];
  233. if ($limitEnumerationGroup) {
  234. if ($currentUser instanceof IUser) {
  235. $currentUserGroups = $this->groupManager->getUserGroupIds($currentUser);
  236. }
  237. }
  238. $searchLimit = $this->config->getSystemValueInt('sharing.maxAutocompleteResults', Constants::SHARING_MAX_AUTOCOMPLETE_RESULTS_DEFAULT);
  239. if ($searchLimit <= 0) {
  240. $searchLimit = null;
  241. }
  242. foreach ($searchProperties as $prop => $value) {
  243. switch ($prop) {
  244. case '{http://sabredav.org/ns}email-address':
  245. if (!$allowEnumeration) {
  246. if ($allowEnumerationFullMatch && $matchEmail) {
  247. $users = $this->userManager->getByEmail($value);
  248. } else {
  249. $users = [];
  250. }
  251. } else {
  252. $users = $this->userManager->getByEmail($value);
  253. $users = \array_filter($users, function (IUser $user) use ($currentUser, $value, $limitEnumerationPhone, $limitEnumerationGroup, $allowEnumerationFullMatch, $currentUserGroups) {
  254. if ($allowEnumerationFullMatch && $user->getSystemEMailAddress() === $value) {
  255. return true;
  256. }
  257. if ($limitEnumerationPhone
  258. && $currentUser instanceof IUser
  259. && $this->knownUserService->isKnownToUser($currentUser->getUID(), $user->getUID())) {
  260. // Synced phonebook match
  261. return true;
  262. }
  263. if (!$limitEnumerationGroup) {
  264. // No limitation on enumeration, all allowed
  265. return true;
  266. }
  267. return !empty($currentUserGroups) && !empty(array_intersect(
  268. $this->groupManager->getUserGroupIds($user),
  269. $currentUserGroups
  270. ));
  271. });
  272. }
  273. $results[] = array_reduce($users, function (array $carry, IUser $user) use ($restrictGroups) {
  274. // is sharing restricted to groups only?
  275. if ($restrictGroups !== false) {
  276. $userGroups = $this->groupManager->getUserGroupIds($user);
  277. if (count(array_intersect($userGroups, $restrictGroups)) === 0) {
  278. return $carry;
  279. }
  280. }
  281. $carry[] = $this->principalPrefix . '/' . $user->getUID();
  282. return $carry;
  283. }, []);
  284. break;
  285. case '{DAV:}displayname':
  286. if (!$allowEnumeration) {
  287. if ($allowEnumerationFullMatch) {
  288. $lowerSearch = strtolower($value);
  289. $users = $this->userManager->searchDisplayName($value, $searchLimit);
  290. $users = \array_filter($users, static function (IUser $user) use ($lowerSearch, $ignoreSecondDisplayName) {
  291. $lowerDisplayName = strtolower($user->getDisplayName());
  292. return $lowerDisplayName === $lowerSearch || ($ignoreSecondDisplayName && trim(preg_replace('/ \(.*\)$/', '', $lowerDisplayName)) === $lowerSearch);
  293. });
  294. } else {
  295. $users = [];
  296. }
  297. } else {
  298. $users = $this->userManager->searchDisplayName($value, $searchLimit);
  299. $users = \array_filter($users, function (IUser $user) use ($currentUser, $value, $limitEnumerationPhone, $limitEnumerationGroup, $allowEnumerationFullMatch, $currentUserGroups) {
  300. if ($allowEnumerationFullMatch && $user->getDisplayName() === $value) {
  301. return true;
  302. }
  303. if ($limitEnumerationPhone
  304. && $currentUser instanceof IUser
  305. && $this->knownUserService->isKnownToUser($currentUser->getUID(), $user->getUID())) {
  306. // Synced phonebook match
  307. return true;
  308. }
  309. if (!$limitEnumerationGroup) {
  310. // No limitation on enumeration, all allowed
  311. return true;
  312. }
  313. return !empty($currentUserGroups) && !empty(array_intersect(
  314. $this->groupManager->getUserGroupIds($user),
  315. $currentUserGroups
  316. ));
  317. });
  318. }
  319. $results[] = array_reduce($users, function (array $carry, IUser $user) use ($restrictGroups) {
  320. // is sharing restricted to groups only?
  321. if ($restrictGroups !== false) {
  322. $userGroups = $this->groupManager->getUserGroupIds($user);
  323. if (count(array_intersect($userGroups, $restrictGroups)) === 0) {
  324. return $carry;
  325. }
  326. }
  327. $carry[] = $this->principalPrefix . '/' . $user->getUID();
  328. return $carry;
  329. }, []);
  330. break;
  331. case '{urn:ietf:params:xml:ns:caldav}calendar-user-address-set':
  332. // If you add support for more search properties that qualify as a user-address,
  333. // please also add them to the array below
  334. $results[] = $this->searchUserPrincipals([
  335. // In theory this should also search for principal:principals/users/...
  336. // but that's used internally only anyway and i don't know of any client querying that
  337. '{http://sabredav.org/ns}email-address' => $value,
  338. ], 'anyof');
  339. break;
  340. default:
  341. $results[] = [];
  342. break;
  343. }
  344. }
  345. // results is an array of arrays, so this is not the first search result
  346. // but the results of the first searchProperty
  347. if (count($results) === 1) {
  348. return $results[0];
  349. }
  350. switch ($test) {
  351. case 'anyof':
  352. return array_values(array_unique(array_merge(...$results)));
  353. case 'allof':
  354. default:
  355. return array_values(array_intersect(...$results));
  356. }
  357. }
  358. /**
  359. * @param string $prefixPath
  360. * @param array $searchProperties
  361. * @param string $test
  362. * @return array
  363. */
  364. public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') {
  365. if (count($searchProperties) === 0) {
  366. return [];
  367. }
  368. switch ($prefixPath) {
  369. case 'principals/users':
  370. return $this->searchUserPrincipals($searchProperties, $test);
  371. default:
  372. return [];
  373. }
  374. }
  375. /**
  376. * @param string $uri
  377. * @param string $principalPrefix
  378. * @return string
  379. */
  380. public function findByUri($uri, $principalPrefix) {
  381. // If sharing is disabled, return the empty array
  382. $shareAPIEnabled = $this->shareManager->shareApiEnabled();
  383. if (!$shareAPIEnabled) {
  384. return null;
  385. }
  386. // If sharing is restricted to group members only,
  387. // return only members that have groups in common
  388. $restrictGroups = false;
  389. if ($this->shareManager->shareWithGroupMembersOnly()) {
  390. $user = $this->userSession->getUser();
  391. if (!$user) {
  392. return null;
  393. }
  394. $restrictGroups = $this->groupManager->getUserGroupIds($user);
  395. }
  396. if (str_starts_with($uri, 'mailto:')) {
  397. if ($principalPrefix === 'principals/users') {
  398. $users = $this->userManager->getByEmail(substr($uri, 7));
  399. if (count($users) !== 1) {
  400. return null;
  401. }
  402. $user = $users[0];
  403. if ($restrictGroups !== false) {
  404. $userGroups = $this->groupManager->getUserGroupIds($user);
  405. if (count(array_intersect($userGroups, $restrictGroups)) === 0) {
  406. return null;
  407. }
  408. }
  409. return $this->principalPrefix . '/' . $user->getUID();
  410. }
  411. }
  412. if (str_starts_with($uri, 'principal:')) {
  413. $principal = substr($uri, 10);
  414. $principal = $this->getPrincipalByPath($principal);
  415. if ($principal !== null) {
  416. return $principal['uri'];
  417. }
  418. }
  419. return null;
  420. }
  421. /**
  422. * @param IUser $user
  423. * @return array
  424. * @throws PropertyDoesNotExistException
  425. */
  426. protected function userToPrincipal($user) {
  427. $userId = $user->getUID();
  428. $displayName = $user->getDisplayName();
  429. $principal = [
  430. 'uri' => $this->principalPrefix . '/' . $userId,
  431. '{DAV:}displayname' => is_null($displayName) ? $userId : $displayName,
  432. '{urn:ietf:params:xml:ns:caldav}calendar-user-type' => 'INDIVIDUAL',
  433. '{http://nextcloud.com/ns}language' => $this->languageFactory->getUserLanguage($user),
  434. ];
  435. $account = $this->accountManager->getAccount($user);
  436. $alternativeEmails = array_map(fn (IAccountProperty $property) => 'mailto:' . $property->getValue(), $account->getPropertyCollection(IAccountManager::COLLECTION_EMAIL)->getProperties());
  437. $email = $user->getSystemEMailAddress();
  438. if (!empty($email)) {
  439. $principal['{http://sabredav.org/ns}email-address'] = $email;
  440. }
  441. if (!empty($alternativeEmails)) {
  442. $principal['{DAV:}alternate-URI-set'] = $alternativeEmails;
  443. }
  444. return $principal;
  445. }
  446. public function getPrincipalPrefix() {
  447. return $this->principalPrefix;
  448. }
  449. /**
  450. * @param string $circleUniqueId
  451. * @return array|null
  452. */
  453. protected function circleToPrincipal($circleUniqueId) {
  454. if (!$this->appManager->isEnabledForUser('circles') || !class_exists('\OCA\Circles\Api\v1\Circles')) {
  455. return null;
  456. }
  457. try {
  458. $circle = \OCA\Circles\Api\v1\Circles::detailsCircle($circleUniqueId, true);
  459. } catch (QueryException $ex) {
  460. return null;
  461. } catch (CircleNotFoundException $ex) {
  462. return null;
  463. }
  464. if (!$circle) {
  465. return null;
  466. }
  467. $principal = [
  468. 'uri' => 'principals/circles/' . $circleUniqueId,
  469. '{DAV:}displayname' => $circle->getDisplayName(),
  470. ];
  471. return $principal;
  472. }
  473. /**
  474. * Returns the list of circles a principal is a member of
  475. *
  476. * @param string $principal
  477. * @return array
  478. * @throws Exception
  479. * @throws \OCP\AppFramework\QueryException
  480. * @suppress PhanUndeclaredClassMethod
  481. */
  482. public function getCircleMembership($principal):array {
  483. if (!$this->appManager->isEnabledForUser('circles') || !class_exists('\OCA\Circles\Api\v1\Circles')) {
  484. return [];
  485. }
  486. [$prefix, $name] = \Sabre\Uri\split($principal);
  487. if ($this->hasCircles && $prefix === $this->principalPrefix) {
  488. $user = $this->userManager->get($name);
  489. if (!$user) {
  490. throw new Exception('Principal not found');
  491. }
  492. $circles = \OCA\Circles\Api\v1\Circles::joinedCircles($name, true);
  493. $circles = array_map(function ($circle) {
  494. /** @var \OCA\Circles\Model\Circle $circle */
  495. return 'principals/circles/' . urlencode($circle->getSingleId());
  496. }, $circles);
  497. return $circles;
  498. }
  499. return [];
  500. }
  501. /**
  502. * Get all email addresses associated to a principal.
  503. *
  504. * @param array $principal Data from getPrincipal*()
  505. * @return string[] All email addresses without the mailto: prefix
  506. */
  507. public function getEmailAddressesOfPrincipal(array $principal): array {
  508. $emailAddresses = [];
  509. if (isset($principal['{http://sabredav.org/ns}email-address'])) {
  510. $emailAddresses[] = $principal['{http://sabredav.org/ns}email-address'];
  511. }
  512. if (isset($principal['{DAV:}alternate-URI-set'])) {
  513. foreach ($principal['{DAV:}alternate-URI-set'] as $address) {
  514. if (str_starts_with($address, 'mailto:')) {
  515. $emailAddresses[] = substr($address, 7);
  516. }
  517. }
  518. }
  519. if (isset($principal['{urn:ietf:params:xml:ns:caldav}calendar-user-address-set'])) {
  520. foreach ($principal['{urn:ietf:params:xml:ns:caldav}calendar-user-address-set'] as $address) {
  521. if (str_starts_with($address, 'mailto:')) {
  522. $emailAddresses[] = substr($address, 7);
  523. }
  524. }
  525. }
  526. if (isset($principal['{http://calendarserver.org/ns/}email-address-set'])) {
  527. foreach ($principal['{http://calendarserver.org/ns/}email-address-set'] as $address) {
  528. if (str_starts_with($address, 'mailto:')) {
  529. $emailAddresses[] = substr($address, 7);
  530. }
  531. }
  532. }
  533. return array_values(array_unique($emailAddresses));
  534. }
  535. }