ContactsStore.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-License-Identifier: AGPL-3.0-or-later
  5. */
  6. namespace OC\Contacts\ContactsMenu;
  7. use OC\KnownUser\KnownUserService;
  8. use OC\Profile\ProfileManager;
  9. use OCA\UserStatus\Db\UserStatus;
  10. use OCA\UserStatus\Service\StatusService;
  11. use OCP\Contacts\ContactsMenu\IContactsStore;
  12. use OCP\Contacts\ContactsMenu\IEntry;
  13. use OCP\Contacts\IManager;
  14. use OCP\IConfig;
  15. use OCP\IGroupManager;
  16. use OCP\IURLGenerator;
  17. use OCP\IUser;
  18. use OCP\IUserManager;
  19. use OCP\L10N\IFactory as IL10NFactory;
  20. use function array_column;
  21. use function array_fill_keys;
  22. use function array_filter;
  23. use function array_key_exists;
  24. use function array_merge;
  25. use function count;
  26. class ContactsStore implements IContactsStore {
  27. public function __construct(
  28. private IManager $contactsManager,
  29. private ?StatusService $userStatusService,
  30. private IConfig $config,
  31. private ProfileManager $profileManager,
  32. private IUserManager $userManager,
  33. private IURLGenerator $urlGenerator,
  34. private IGroupManager $groupManager,
  35. private KnownUserService $knownUserService,
  36. private IL10NFactory $l10nFactory,
  37. ) {
  38. }
  39. /**
  40. * @return IEntry[]
  41. */
  42. public function getContacts(IUser $user, ?string $filter, ?int $limit = null, ?int $offset = null): array {
  43. $options = [
  44. 'enumeration' => $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes',
  45. 'fullmatch' => $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_full_match', 'yes') === 'yes',
  46. ];
  47. if ($limit !== null) {
  48. $options['limit'] = $limit;
  49. }
  50. if ($offset !== null) {
  51. $options['offset'] = $offset;
  52. }
  53. // Status integration only works without pagination and filters
  54. if ($offset === null && ($filter === null || $filter === '')) {
  55. $recentStatuses = $this->userStatusService?->findAllRecentStatusChanges($limit, $offset) ?? [];
  56. } else {
  57. $recentStatuses = [];
  58. }
  59. // Search by status if there is no filter and statuses are available
  60. if (!empty($recentStatuses)) {
  61. $allContacts = array_filter(array_map(function (UserStatus $userStatus) use ($options) {
  62. // UID is ambiguous with federation. We have to use the federated cloud ID to an exact match of
  63. // A local user
  64. $user = $this->userManager->get($userStatus->getUserId());
  65. if ($user === null) {
  66. return null;
  67. }
  68. $contact = $this->contactsManager->search(
  69. $user->getCloudId(),
  70. [
  71. 'CLOUD',
  72. ],
  73. array_merge(
  74. $options,
  75. [
  76. 'limit' => 1,
  77. 'offset' => 0,
  78. ],
  79. ),
  80. )[0] ?? null;
  81. if ($contact !== null) {
  82. $contact[Entry::PROPERTY_STATUS_MESSAGE_TIMESTAMP] = $userStatus->getStatusMessageTimestamp();
  83. }
  84. return $contact;
  85. }, $recentStatuses));
  86. if ($limit !== null && count($allContacts) < $limit) {
  87. // More contacts were requested
  88. $fromContacts = $this->contactsManager->search(
  89. $filter ?? '',
  90. [
  91. 'FN',
  92. 'EMAIL'
  93. ],
  94. array_merge(
  95. $options,
  96. [
  97. 'limit' => $limit - count($allContacts),
  98. ],
  99. ),
  100. );
  101. // Create hash map of all status contacts
  102. $existing = array_fill_keys(array_column($allContacts, 'URI'), null);
  103. // Append the ones that are new
  104. $allContacts = array_merge(
  105. $allContacts,
  106. array_filter($fromContacts, fn (array $contact): bool => !array_key_exists($contact['URI'], $existing))
  107. );
  108. }
  109. } else {
  110. $allContacts = $this->contactsManager->search(
  111. $filter ?? '',
  112. [
  113. 'FN',
  114. 'EMAIL'
  115. ],
  116. $options
  117. );
  118. }
  119. $userId = $user->getUID();
  120. $contacts = array_filter($allContacts, function ($contact) use ($userId) {
  121. // When searching for multiple results, we strip out the current user
  122. if (array_key_exists('UID', $contact)) {
  123. return $contact['UID'] !== $userId;
  124. }
  125. return true;
  126. });
  127. $entries = array_map(function (array $contact) {
  128. return $this->contactArrayToEntry($contact);
  129. }, $contacts);
  130. return $this->filterContacts(
  131. $user,
  132. $entries,
  133. $filter
  134. );
  135. }
  136. /**
  137. * Filters the contacts. Applied filters:
  138. * 1. if the `shareapi_allow_share_dialog_user_enumeration` config option is
  139. * enabled it will filter all local users
  140. * 2. if the `shareapi_exclude_groups` config option is enabled and the
  141. * current user is in an excluded group it will filter all local users.
  142. * 3. if the `shareapi_only_share_with_group_members` config option is
  143. * enabled it will filter all users which doesn't have a common group
  144. * with the current user.
  145. * If enabled, the 'shareapi_only_share_with_group_members_exclude_group_list'
  146. * config option may specify some groups excluded from the principle of
  147. * belonging to the same group.
  148. *
  149. * @param Entry[] $entries
  150. * @return Entry[] the filtered contacts
  151. */
  152. private function filterContacts(
  153. IUser $self,
  154. array $entries,
  155. ?string $filter,
  156. ): array {
  157. $disallowEnumeration = $this->config->getAppValue('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') !== 'yes';
  158. $restrictEnumerationGroup = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
  159. $restrictEnumerationPhone = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
  160. $allowEnumerationFullMatch = $this->config->getAppValue('core', 'shareapi_restrict_user_enumeration_full_match', 'yes') === 'yes';
  161. $excludeGroups = $this->config->getAppValue('core', 'shareapi_exclude_groups', 'no');
  162. // whether to filter out local users
  163. $skipLocal = false;
  164. // whether to filter out all users which don't have a common group as the current user
  165. $ownGroupsOnly = $this->config->getAppValue('core', 'shareapi_only_share_with_group_members', 'no') === 'yes';
  166. $selfGroups = $this->groupManager->getUserGroupIds($self);
  167. if ($excludeGroups && $excludeGroups !== 'no') {
  168. $excludedGroups = $this->config->getAppValue('core', 'shareapi_exclude_groups_list', '');
  169. $decodedExcludeGroups = json_decode($excludedGroups, true);
  170. $excludeGroupsList = $decodedExcludeGroups ?? [];
  171. if ($excludeGroups != 'allow') {
  172. if (count(array_intersect($excludeGroupsList, $selfGroups)) !== 0) {
  173. // a group of the current user is excluded -> filter all local users
  174. $skipLocal = true;
  175. }
  176. } else {
  177. $skipLocal = true;
  178. if (count(array_intersect($excludeGroupsList, $selfGroups)) !== 0) {
  179. // a group of the current user is allowed -> do not filter all local users
  180. $skipLocal = false;
  181. }
  182. }
  183. }
  184. // ownGroupsOnly : some groups may be excluded
  185. if ($ownGroupsOnly) {
  186. $excludeGroupsFromOwnGroups = $this->config->getAppValue('core', 'shareapi_only_share_with_group_members_exclude_group_list', '');
  187. $excludeGroupsFromOwnGroupsList = json_decode($excludeGroupsFromOwnGroups, true) ?? [];
  188. $selfGroups = array_diff($selfGroups, $excludeGroupsFromOwnGroupsList);
  189. }
  190. $selfUID = $self->getUID();
  191. return array_values(array_filter($entries, function (IEntry $entry) use ($skipLocal, $ownGroupsOnly, $selfGroups, $selfUID, $disallowEnumeration, $restrictEnumerationGroup, $restrictEnumerationPhone, $allowEnumerationFullMatch, $filter) {
  192. if ($entry->getProperty('isLocalSystemBook')) {
  193. if ($skipLocal) {
  194. return false;
  195. }
  196. $checkedCommonGroupAlready = false;
  197. // Prevent enumerating local users
  198. if ($disallowEnumeration) {
  199. if (!$allowEnumerationFullMatch) {
  200. return false;
  201. }
  202. $filterOutUser = true;
  203. $mailAddresses = $entry->getEMailAddresses();
  204. foreach ($mailAddresses as $mailAddress) {
  205. if ($mailAddress === $filter) {
  206. $filterOutUser = false;
  207. break;
  208. }
  209. }
  210. if ($entry->getProperty('UID') && $entry->getProperty('UID') === $filter) {
  211. $filterOutUser = false;
  212. }
  213. if ($filterOutUser) {
  214. return false;
  215. }
  216. } elseif ($restrictEnumerationPhone || $restrictEnumerationGroup) {
  217. $canEnumerate = false;
  218. if ($restrictEnumerationPhone) {
  219. $canEnumerate = $this->knownUserService->isKnownToUser($selfUID, $entry->getProperty('UID'));
  220. }
  221. if (!$canEnumerate && $restrictEnumerationGroup) {
  222. $user = $this->userManager->get($entry->getProperty('UID'));
  223. if ($user === null) {
  224. return false;
  225. }
  226. $contactGroups = $this->groupManager->getUserGroupIds($user);
  227. $canEnumerate = !empty(array_intersect($contactGroups, $selfGroups));
  228. $checkedCommonGroupAlready = true;
  229. }
  230. if (!$canEnumerate) {
  231. return false;
  232. }
  233. }
  234. if ($ownGroupsOnly && !$checkedCommonGroupAlready) {
  235. $user = $this->userManager->get($entry->getProperty('UID'));
  236. if (!$user instanceof IUser) {
  237. return false;
  238. }
  239. $contactGroups = $this->groupManager->getUserGroupIds($user);
  240. if (empty(array_intersect($contactGroups, $selfGroups))) {
  241. // no groups in common, so shouldn't see the contact
  242. return false;
  243. }
  244. }
  245. }
  246. return true;
  247. }));
  248. }
  249. public function findOne(IUser $user, int $shareType, string $shareWith): ?IEntry {
  250. switch ($shareType) {
  251. case 0:
  252. case 6:
  253. $filter = ['UID'];
  254. break;
  255. case 4:
  256. $filter = ['EMAIL'];
  257. break;
  258. default:
  259. return null;
  260. }
  261. $contacts = $this->contactsManager->search($shareWith, $filter, [
  262. 'strict_search' => true,
  263. ]);
  264. $match = null;
  265. foreach ($contacts as $contact) {
  266. if ($shareType === 4 && isset($contact['EMAIL'])) {
  267. if (in_array($shareWith, $contact['EMAIL'])) {
  268. $match = $contact;
  269. break;
  270. }
  271. }
  272. if ($shareType === 0 || $shareType === 6) {
  273. $isLocal = $contact['isLocalSystemBook'] ?? false;
  274. if ($contact['UID'] === $shareWith && $isLocal === true) {
  275. $match = $contact;
  276. break;
  277. }
  278. }
  279. }
  280. if ($match) {
  281. $match = $this->filterContacts($user, [$this->contactArrayToEntry($match)], $shareWith);
  282. if (count($match) === 1) {
  283. $match = $match[0];
  284. } else {
  285. $match = null;
  286. }
  287. }
  288. return $match;
  289. }
  290. private function contactArrayToEntry(array $contact): Entry {
  291. $entry = new Entry();
  292. if (!empty($contact['UID'])) {
  293. $uid = $contact['UID'];
  294. $entry->setId($uid);
  295. $entry->setProperty('isUser', false);
  296. // overloaded usage so leaving as-is for now
  297. if (isset($contact['isLocalSystemBook'])) {
  298. $avatar = $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $uid, 'size' => 64]);
  299. $entry->setProperty('isUser', true);
  300. } elseif (!empty($contact['FN'])) {
  301. $avatar = $this->urlGenerator->linkToRouteAbsolute('core.GuestAvatar.getAvatar', ['guestName' => str_replace('/', ' ', $contact['FN']), 'size' => 64]);
  302. } else {
  303. $avatar = $this->urlGenerator->linkToRouteAbsolute('core.GuestAvatar.getAvatar', ['guestName' => str_replace('/', ' ', $uid), 'size' => 64]);
  304. }
  305. $entry->setAvatar($avatar);
  306. }
  307. if (!empty($contact['FN'])) {
  308. $entry->setFullName($contact['FN']);
  309. }
  310. $avatarPrefix = 'VALUE=uri:';
  311. if (!empty($contact['PHOTO']) && str_starts_with($contact['PHOTO'], $avatarPrefix)) {
  312. $entry->setAvatar(substr($contact['PHOTO'], strlen($avatarPrefix)));
  313. }
  314. if (!empty($contact['EMAIL'])) {
  315. foreach ($contact['EMAIL'] as $email) {
  316. $entry->addEMailAddress($email);
  317. }
  318. }
  319. // Provide profile parameters for core/src/OC/contactsmenu/contact.handlebars template
  320. if (!empty($contact['UID']) && !empty($contact['FN'])) {
  321. $targetUserId = $contact['UID'];
  322. $targetUser = $this->userManager->get($targetUserId);
  323. if (!empty($targetUser)) {
  324. if ($this->profileManager->isProfileEnabled($targetUser)) {
  325. $entry->setProfileTitle($this->l10nFactory->get('lib')->t('View profile'));
  326. $entry->setProfileUrl($this->urlGenerator->linkToRouteAbsolute('core.ProfilePage.index', ['targetUserId' => $targetUserId]));
  327. }
  328. }
  329. }
  330. // Attach all other properties to the entry too because some
  331. // providers might make use of it.
  332. $entry->setProperties($contact);
  333. return $entry;
  334. }
  335. }