Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.16% covered (success)
92.16%
94 / 102
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContactPhonePlugin
92.16% covered (success)
92.16%
94 / 102
33.33% covered (danger)
33.33%
1 / 3
36.63
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 search
96.47% covered (success)
96.47%
82 / 85
0.00% covered (danger)
0.00%
0 / 1
26
 extractPhoneValues
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
11.47
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Collaboration\Collaborators;
10
11use OC\KnownUser\KnownUserService;
12use OCA\Libresign\Service\Identify\SearchNormalizer;
13use OCA\Libresign\Service\Identify\SignerSearchContext;
14use OCA\Libresign\Service\IdentifyMethodService;
15use OCP\Collaboration\Collaborators\ISearchPlugin;
16use OCP\Collaboration\Collaborators\ISearchResult;
17use OCP\Collaboration\Collaborators\SearchResultType;
18use OCP\Contacts\IManager;
19use OCP\IAppConfig;
20use OCP\IGroupManager;
21use OCP\IUserManager;
22use OCP\IUserSession;
23
24class ContactPhonePlugin implements ISearchPlugin {
25    public const TYPE_SIGNER_CONTACT_PHONE = 52;
26
27    public function __construct(
28        private IAppConfig $appConfig,
29        private IManager $contactsManager,
30        private IGroupManager $groupManager,
31        private IUserManager $userManager,
32        private IUserSession $userSession,
33        private KnownUserService $knownUserService,
34        private SignerSearchContext $searchContext,
35        private SearchNormalizer $searchNormalizer,
36    ) {
37    }
38
39    /**
40     * {@inheritdoc}
41     */
42    #[\Override]
43    public function search($search, $limit, $offset, ISearchResult $searchResult): bool {
44        $method = $this->searchContext->getMethod();
45        $search = trim((string)$search);
46
47        if ($search === '' || !in_array($method, IdentifyMethodService::IDENTIFY_PHONE_METHODS, true)) {
48            return false;
49        }
50
51        $shareeEnumeration = $this->appConfig->getValueString('core', 'shareapi_allow_share_dialog_user_enumeration', 'yes') === 'yes';
52        $shareeEnumerationFullMatch = $this->appConfig->getValueString('core', 'shareapi_restrict_user_enumeration_full_match', 'yes') === 'yes';
53        if (!$shareeEnumeration && !$shareeEnumerationFullMatch) {
54            return false;
55        }
56        $shareeEnumerationRestrictToGroup = $this->appConfig->getValueString('core', 'shareapi_restrict_user_enumeration_to_group', 'no') === 'yes';
57        $shareeEnumerationRestrictToPhone = $this->appConfig->getValueString('core', 'shareapi_restrict_user_enumeration_to_phone', 'no') === 'yes';
58        $shareWithGroupOnly = $this->appConfig->getValueString('core', 'shareapi_only_share_with_group_members', 'no') === 'yes';
59        $shareWithGroupOnlyExcludeGroupsList = json_decode(
60            $this->appConfig->getValueString('core', 'shareapi_only_share_with_group_members_exclude_group_list', '[]'),
61            true,
62            512,
63            JSON_THROW_ON_ERROR
64        ) ?? [];
65        $currentUser = $this->userSession->getUser();
66        if ($currentUser === null) {
67            return false;
68        }
69        $allowedGroups = array_diff($this->groupManager->getUserGroupIds($currentUser), $shareWithGroupOnlyExcludeGroupsList);
70
71        if (!$this->contactsManager->isEnabled()) {
72            return false;
73        }
74
75        $max = $offset + $limit + 1;
76        $rows = [];
77        $seen = [];
78
79        $contacts = $this->contactsManager->search(
80            $search,
81            ['TEL', 'FN'],
82            [
83                'limit' => $max,
84                'offset' => 0,
85                'types' => true,
86                'enumeration' => $shareeEnumeration,
87                'fullmatch' => $shareeEnumerationFullMatch,
88            ]
89        );
90
91        foreach ($contacts as $contact) {
92            if (!empty($contact['isLocalSystemBook'])) {
93                $contactUserId = $contact['UID'] ?? null;
94                if (!is_string($contactUserId) || $contactUserId === '') {
95                    continue;
96                }
97
98                $contactUser = $this->userManager->get($contactUserId);
99                if ($contactUser === null) {
100                    continue;
101                }
102                $contactGroups = $this->groupManager->getUserGroupIds($contactUser);
103                $inAllowedGroup = !empty(array_intersect($contactGroups, $allowedGroups));
104
105                if ($shareeEnumerationRestrictToGroup && !$inAllowedGroup) {
106                    continue;
107                }
108                if ($shareeEnumerationRestrictToPhone
109                    && !$this->knownUserService->isKnownToUser($currentUser->getUID(), $contactUserId)) {
110                    continue;
111                }
112                if ($shareWithGroupOnly && !$inAllowedGroup) {
113                    continue;
114                }
115            }
116
117            $displayName = $contact['FN'] ?? '';
118            foreach ($this->extractPhoneValues($contact) as $phoneValue) {
119                $normalizedPhone = $this->searchNormalizer->tryNormalizePhoneNumber($phoneValue, $method);
120                if ($normalizedPhone === null) {
121                    continue;
122                }
123
124                if (isset($seen[$normalizedPhone])) {
125                    continue;
126                }
127                $rows[] = [
128                    'label' => $displayName !== '' ? $displayName : $normalizedPhone,
129                    'shareWithDisplayNameUnique' => $normalizedPhone,
130                    'method' => $method,
131                    'value' => [
132                        'shareType' => self::TYPE_SIGNER_CONTACT_PHONE,
133                        'shareWith' => $normalizedPhone,
134                    ],
135                ];
136                $seen[$normalizedPhone] = true;
137                if (count($rows) >= $max) {
138                    break 2;
139                }
140            }
141        }
142
143        $hasMore = count($rows) > ($offset + $limit);
144        $pagedRows = array_slice($rows, $offset, $limit);
145
146        $result = ['wide' => [], 'exact' => []];
147        $searchLower = strtolower($search);
148        foreach ($pagedRows as $item) {
149            if (strtolower($item['shareWithDisplayNameUnique']) === $searchLower
150                || strtolower((string)$item['label']) === $searchLower
151            ) {
152                $result['exact'][] = $item;
153            } else {
154                $result['wide'][] = $item;
155            }
156        }
157
158        $type = new SearchResultType('contact-phone');
159        $searchResult->addResultSet($type, $result['wide'], $result['exact']);
160
161        return $hasMore;
162    }
163
164    private function extractPhoneValues(array $contact): array {
165        if (!isset($contact['TEL'])) {
166            return [];
167        }
168        $phones = $contact['TEL'];
169        if (is_string($phones)) {
170            return [$phones];
171        }
172        if (is_array($phones) && isset($phones['value'])) {
173            $phones = [$phones];
174        }
175        if (!is_array($phones)) {
176            return [];
177        }
178        $values = [];
179        foreach ($phones as $phone) {
180            if (is_array($phone)) {
181                $values[] = (string)($phone['value'] ?? '');
182            } elseif (is_string($phone)) {
183                $values[] = $phone;
184            }
185        }
186        return array_filter($values, fn (string $value) => $value !== '');
187    }
188}