3 * @copyright Copyright (C) 2010-2023, the Friendica project
5 * @license GNU AGPL version 3 or any later version
7 * This program is free software: you can redistribute it and/or modify
8 * it under the terms of the GNU Affero General Public License as
9 * published by the Free Software Foundation, either version 3 of the
10 * License, or (at your option) any later version.
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU Affero General Public License for more details.
17 * You should have received a copy of the GNU Affero General Public License
18 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22 namespace Friendica\Module\Search;
25 use Friendica\BaseModule;
26 use Friendica\Content\Widget;
27 use Friendica\Core\Hook;
28 use Friendica\Core\L10n;
29 use Friendica\Core\Protocol;
30 use Friendica\Core\Search;
31 use Friendica\Core\Session\Capability\IHandleUserSessions;
32 use Friendica\Core\System;
33 use Friendica\Database\Database;
34 use Friendica\Database\DBA;
35 use Friendica\Model\Contact;
36 use Friendica\Model\Post;
37 use Friendica\Module\Response;
38 use Friendica\Network\HTTPException;
39 use Friendica\Util\Profiler;
40 use Psr\Log\LoggerInterface;
43 * ACL selector json backend
45 * @package Friendica\Module\Search
47 class Acl extends BaseModule
49 const TYPE_GLOBAL_CONTACT = 'x';
50 const TYPE_MENTION_CONTACT = 'c';
51 const TYPE_MENTION_CIRCLE = 'g';
52 const TYPE_MENTION_CONTACT_CIRCLE = '';
53 const TYPE_MENTION_GROUP = 'f';
54 const TYPE_PRIVATE_MESSAGE = 'm';
55 const TYPE_ANY_CONTACT = 'a';
57 /** @var IHandleUserSessions */
62 public function __construct(Database $database, IHandleUserSessions $session, L10n $l10n, App\BaseURL $baseUrl, App\Arguments $args, LoggerInterface $logger, Profiler $profiler, Response $response, array $server, array $parameters = [])
64 parent::__construct($l10n, $baseUrl, $args, $logger, $profiler, $response, $server, $parameters);
66 $this->session = $session;
67 $this->database = $database;
70 protected function rawContent(array $request = [])
72 if (!$this->session->getLocalUserId()) {
73 throw new HTTPException\UnauthorizedException($this->t('You must be logged in to use this module.'));
76 $type = $request['type'] ?? self::TYPE_MENTION_CONTACT_CIRCLE;
77 if ($type === self::TYPE_GLOBAL_CONTACT) {
78 $o = $this->globalContactSearch($request);
80 $o = $this->regularContactSearch($request, $type);
86 private function globalContactSearch(array $request): array
88 // autocomplete for global contact search (e.g. navbar search)
89 $search = trim($request['search']);
90 $mode = $request['smode'];
91 $page = $request['page'] ?? 1;
93 $result = Search::searchContact($search, $mode, $page);
96 foreach ($result as $contact) {
98 'photo' => Contact::getMicro($contact, true),
99 'name' => htmlspecialchars($contact['name']),
100 'nick' => $contact['addr'] ?: $contact['url'],
101 'network' => $contact['network'],
102 'link' => $contact['url'],
103 'group' => $contact['contact-type'] == Contact::TYPE_COMMUNITY,
108 'start' => ($page - 1) * 20,
110 'items' => $contacts,
114 private function regularContactSearch(array $request, string $type): array
116 $start = $request['start'] ?? 0;
117 $count = $request['count'] ?? 100;
118 $search = $request['search'] ?? '';
119 $conv_id = $request['conversation'] ?? null;
121 // For use with jquery.textcomplete for private mail completion
122 if (!empty($request['query'])) {
124 $type = self::TYPE_PRIVATE_MESSAGE;
126 $search = $request['query'];
129 $this->logger->info('ACL {action} - {subaction} - start', ['module' => 'acl', 'action' => 'content', 'subaction' => 'search', 'search' => $search, 'type' => $type, 'conversation' => $conv_id]);
132 $condition = ["`uid` = ? AND NOT `deleted` AND NOT `pending` AND NOT `archive`", $this->session->getLocalUserId()];
133 $condition_circle = ["`uid` = ? AND NOT `deleted`", $this->session->getLocalUserId()];
136 $sql_extra = "AND `name` LIKE '%%" . $this->database->escape($search) . "%%'";
137 $condition = DBA::mergeConditions($condition, ["(`attag` LIKE ? OR `name` LIKE ? OR `nick` LIKE ?)",
138 '%' . $search . '%', '%' . $search . '%', '%' . $search . '%']);
139 $condition_circle = DBA::mergeConditions($condition_circle, ["`name` LIKE ?", '%' . $search . '%']);
142 // count circles and contacts
144 if ($type == self::TYPE_MENTION_CONTACT_CIRCLE || $type == self::TYPE_MENTION_CIRCLE) {
145 $circle_count = $this->database->count('group', $condition_circle);
148 $networks = Widget::unavailableNetworks();
149 $condition = DBA::mergeConditions($condition, array_merge(["NOT `network` IN (" . substr(str_repeat("?, ", count($networks)), 0, -2) . ")"], $networks));
152 case self::TYPE_MENTION_CONTACT_CIRCLE:
153 $condition = DBA::mergeConditions($condition,
154 ["NOT `self` AND NOT `blocked` AND `notify` != ? AND `network` != ?", '', Protocol::OSTATUS
158 case self::TYPE_MENTION_CONTACT:
159 $condition = DBA::mergeConditions($condition,
160 ["NOT `self` AND NOT `blocked` AND `notify` != ?", ''
164 case self::TYPE_MENTION_GROUP:
165 $condition = DBA::mergeConditions($condition,
166 ["NOT `self` AND NOT `blocked` AND `notify` != ? AND `contact-type` = ?", '', Contact::TYPE_COMMUNITY
170 case self::TYPE_PRIVATE_MESSAGE:
171 $condition = DBA::mergeConditions($condition,
172 ["NOT `self` AND NOT `blocked` AND `notify` != ? AND `network` IN (?, ?, ?)", '', Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::DIASPORA
177 $contact_count = $this->database->count('contact', $condition);
179 $resultTotal = $circle_count + $contact_count;
182 $resultContacts = [];
184 if ($type == self::TYPE_MENTION_CONTACT_CIRCLE || $type == self::TYPE_MENTION_CIRCLE) {
185 /// @todo We should cache this query.
186 // This can be done when we can delete cache entries via wildcard
187 $circles = $this->database->toArray($this->database->p("SELECT `circle`.`id`, `circle`.`name`, GROUP_CONCAT(DISTINCT `circle_member`.`contact-id` SEPARATOR ',') AS uids
188 FROM `group` AS `circle`
189 INNER JOIN `group_member` AS `circle_member` ON `circle_member`.`gid` = `circle`.`id`
190 WHERE NOT `circle`.`deleted` AND `circle`.`uid` = ?
192 GROUP BY `circle`.`name`, `circle`.`id`
193 ORDER BY `circle`.`name`
195 $this->session->getLocalUserId(),
200 foreach ($circles as $circle) {
202 'type' => self::TYPE_MENTION_CIRCLE,
203 'photo' => 'images/twopeople.png',
204 'name' => htmlspecialchars($circle['name']),
205 'id' => intval($circle['id']),
206 'uids' => array_map('intval', explode(',', $circle['uids'])),
211 if ((count($resultCircles) > 0) && ($search == '')) {
212 $resultCircles[] = ['separator' => true];
217 if ($type != self::TYPE_MENTION_CIRCLE) {
218 $contacts = Contact::selectToArray([], $condition, ['order' => ['name']]);
222 foreach ($contacts as $contact) {
224 'type' => self::TYPE_MENTION_CONTACT,
225 'photo' => Contact::getMicro($contact, true),
226 'name' => htmlspecialchars($contact['name']),
227 'id' => intval($contact['id']),
228 'network' => $contact['network'],
229 'link' => $contact['url'],
230 'nick' => htmlentities(($contact['attag'] ?? '') ?: $contact['nick']),
231 'addr' => htmlentities(($contact['addr'] ?? '') ?: $contact['url']),
232 'group' => $contact['contact-type'] == Contact::TYPE_COMMUNITY,
234 if ($entry['group']) {
237 $resultContacts[] = $entry;
243 $groups[] = ['separator' => true];
246 $resultContacts = array_merge($groups, $resultContacts);
249 $resultItems = array_merge($resultCircles, $resultContacts);
252 // In multithreaded posts the conv_id is not the parent of the whole thread
253 $parent_item = Post::selectFirst(['parent'], ['id' => $conv_id]);
255 $conv_id = $parent_item['parent'];
259 * if $conv_id is set, get unknown contacts in thread
260 * but first get known contacts url to filter them out
262 $known_contacts = array_column($resultContacts, 'link');
264 $unknown_contacts = [];
266 $condition = ["`parent` = ?", $conv_id];
267 $params = ['order' => ['author-name' => true]];
268 $authors = Post::selectForUser($this->session->getLocalUserId(), ['author-link'], $condition, $params);
270 while ($author = Post::fetch($authors)) {
271 $item_authors[$author['author-link']] = $author['author-link'];
274 $this->database->close($authors);
276 foreach (array_diff($item_authors, $known_contacts) as $author) {
277 $contact = Contact::getByURL($author, false, ['micro', 'name', 'id', 'network', 'nick', 'addr', 'url', 'forum', 'avatar']);
279 $unknown_contacts[] = [
280 'type' => self::TYPE_MENTION_CONTACT,
281 'photo' => Contact::getMicro($contact, true),
282 'name' => htmlspecialchars($contact['name']),
283 'id' => intval($contact['id']),
284 'network' => $contact['network'],
285 'link' => $contact['url'],
286 'nick' => htmlentities(($contact['nick'] ?? '') ?: $contact['addr']),
287 'addr' => htmlentities(($contact['addr'] ?? '') ?: $contact['url']),
288 'group' => $contact['forum']
293 $resultItems = array_merge($resultItems, $unknown_contacts);
294 $resultTotal += count($unknown_contacts);
298 'tot' => $resultTotal,
301 'circles' => $resultCircles,
302 'contacts' => $resultContacts,
303 'items' => $resultItems,
308 Hook::callAll('acl_lookup_end', $results);
311 'tot' => $results['tot'],
312 'start' => $results['start'],
313 'count' => $results['count'],
314 'items' => $results['items'],
317 $this->logger->info('ACL {action} - {subaction} - done', ['module' => 'acl', 'action' => 'content', 'subaction' => 'search', 'search' => $search, 'type' => $type, 'conversation' => $conv_id]);