Principal.php 17 KB

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