You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

Principal.php 19KB

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