ContactsStore.php 13 KB

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