]> git.mxchange.org Git - friendica.git/blob - src/Model/Contact/Relation.php
9740c3b24ac1f8d4ff1062e579298c8ac77ea94b
[friendica.git] / src / Model / Contact / Relation.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2020, Friendica
4  *
5  * @license GNU AGPL version 3 or any later version
6  *
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.
11  *
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.
16  *
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/>.
19  *
20  */
21
22 namespace Friendica\Model\Contact;
23
24 use Exception;
25 use Friendica\Core\Logger;
26 use Friendica\Core\Protocol;
27 use Friendica\Database\DBA;
28 use Friendica\DI;
29 use Friendica\Model\APContact;
30 use Friendica\Model\Contact;
31 use Friendica\Protocol\ActivityPub;
32 use Friendica\Util\DateTimeFormat;
33 use Friendica\Util\Strings;
34
35 /**
36  * This class provides relationship information based on the `contact-relation` table.
37  * This table is directional (cid = source, relation-cid = target), references public contacts (with uid=0) and records both
38  * follows and the last interaction (likes/comments) on public posts.
39  */
40 class Relation
41 {
42         /**
43          * No discovery of followers/followings
44          */
45         const DISCOVERY_NONE = 0;
46         /**
47          * Discover followers/followings of local contacts
48          */
49         const DISCOVERY_LOCAL = 1;
50         /**
51          * Discover followers/followings of local contacts and contacts that visibly interacted on the system
52          */
53         const DISCOVERY_INTERACTOR = 2;
54         /**
55          * Discover followers/followings of all contacts
56          */
57         const DISCOVERY_ALL = 3;
58
59         public static function store(int $target, int $actor, string $interaction_date)
60         {
61                 if ($actor == $target) {
62                         return;
63                 }
64
65                 DBA::update('contact-relation', ['last-interaction' => $interaction_date], ['cid' => $target, 'relation-cid' => $actor], true);
66         }
67
68         /**
69          * Fetches the followers of a given profile and adds them
70          *
71          * @param string $url URL of a profile
72          * @return void
73          */
74         public static function discoverByUrl(string $url)
75         {
76                 $contact = Contact::getByURL($url);
77                 if (empty($contact)) {
78                         return;
79                 }
80
81                 if (!self::isDiscoverable($url, $contact)) {
82                         return;
83                 }
84
85                 $apcontact = APContact::getByURL($url, false);
86
87                 if (!empty($apcontact['followers']) && is_string($apcontact['followers'])) {
88                         $followers = ActivityPub::fetchItems($apcontact['followers']);
89                 } else {
90                         $followers = [];
91                 }
92
93                 if (!empty($apcontact['following']) && is_string($apcontact['following'])) {
94                         $followings = ActivityPub::fetchItems($apcontact['following']);
95                 } else {
96                         $followings = [];
97                 }
98
99                 if (empty($followers) && empty($followings)) {
100                         DBA::update('contact', ['last-discovery' => DateTimeFormat::utcNow()], ['id' => $contact['id']]);
101                         Logger::info('The contact does not offer discoverable data', ['id' => $contact['id'], 'url' => $url, 'network' => $contact['network']]);
102                         return;
103                 }
104
105                 $target = $contact['id'];
106
107                 if (!empty($followers)) {
108                         // Clear the follower list, since it will be recreated in the next step
109                         DBA::update('contact-relation', ['follows' => false], ['cid' => $target]);
110                 }
111
112                 $contacts = [];
113                 foreach (array_merge($followers, $followings) as $contact) {
114                         if (is_string($contact)) {
115                                 $contacts[] = $contact;
116                         } elseif (!empty($contact['url']) && is_string($contact['url'])) {
117                                 $contacts[] = $contact['url'];
118                         }
119                 }
120                 $contacts = array_unique($contacts);
121
122                 $follower_counter = 0;
123                 $following_counter = 0;
124
125                 Logger::info('Discover contacts', ['id' => $target, 'url' => $url, 'contacts' => count($contacts)]);
126                 foreach ($contacts as $contact) {
127                         $actor = Contact::getIdForURL($contact);
128                         if (!empty($actor)) {
129                                 if (in_array($contact, $followers)) {
130                                         $fields = ['cid' => $target, 'relation-cid' => $actor];
131                                         DBA::update('contact-relation', ['follows' => true, 'follow-updated' => DateTimeFormat::utcNow()], $fields, true);
132                                         $follower_counter++;
133                                 }
134
135                                 if (in_array($contact, $followings)) {
136                                         $fields = ['cid' => $actor, 'relation-cid' => $target];
137                                         DBA::update('contact-relation', ['follows' => true, 'follow-updated' => DateTimeFormat::utcNow()], $fields, true);
138                                         $following_counter++;
139                                 }
140                         }
141                 }
142
143                 if (!empty($followers)) {
144                         // Delete all followers that aren't followers anymore (and aren't interacting)
145                         DBA::delete('contact-relation', ['cid' => $target, 'follows' => false, 'last-interaction' => DBA::NULL_DATETIME]);
146                 }
147
148                 DBA::update('contact', ['last-discovery' => DateTimeFormat::utcNow()], ['id' => $target]);
149                 Logger::info('Contacts discovery finished', ['id' => $target, 'url' => $url, 'follower' => $follower_counter, 'following' => $following_counter]);
150                 return;
151         }
152
153         /**
154          * Tests if a given contact url is discoverable
155          *
156          * @param string $url     Contact url
157          * @param array  $contact Contact array
158          * @return boolean True if contact is discoverable
159          */
160         public static function isDiscoverable(string $url, array $contact = [])
161         {
162                 $contact_discovery = DI::config()->get('system', 'contact_discovery');
163
164                 if ($contact_discovery == self::DISCOVERY_NONE) {
165                         return false;
166                 }
167
168                 if (empty($contact)) {
169                         $contact = Contact::getByURL($url);
170                 }
171
172                 if (empty($contact)) {
173                         return false;
174                 }
175
176                 if ($contact['last-discovery'] > DateTimeFormat::utc('now - 1 month')) {
177                         Logger::info('No discovery - Last was less than a month ago.', ['id' => $contact['id'], 'url' => $url, 'discovery' => $contact['last-discovery']]);
178                         return false;
179                 }
180
181                 if ($contact_discovery != self::DISCOVERY_ALL) {
182                         $local = DBA::exists('contact', ["`nurl` = ? AND `uid` != ?", Strings::normaliseLink($url), 0]);
183                         if (($contact_discovery == self::DISCOVERY_LOCAL) && !$local) {
184                                 Logger::info('No discovery - This contact is not followed/following locally.', ['id' => $contact['id'], 'url' => $url]);
185                                 return false;
186                         }
187
188                         if ($contact_discovery == self::DISCOVERY_INTERACTOR) {
189                                 $interactor = DBA::exists('contact-relation', ["`relation-cid` = ? AND `last-interaction` > ?", $contact['id'], DBA::NULL_DATETIME]);
190                                 if (!$local && !$interactor) {
191                                         Logger::info('No discovery - This contact is not interacting locally.', ['id' => $contact['id'], 'url' => $url]);
192                                         return false;
193                                 }
194                         }
195                 } elseif ($contact['created'] > DateTimeFormat::utc('now - 1 day')) {
196                         // Newly created contacts are not discovered to avoid DDoS attacks
197                         Logger::info('No discovery - Contact record is less than a day old.', ['id' => $contact['id'], 'url' => $url, 'discovery' => $contact['created']]);
198                         return false;
199                 }
200
201                 if (!in_array($contact['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::OSTATUS])) {
202                         $apcontact = APContact::getByURL($url, false);
203                         if (empty($apcontact)) {
204                                 Logger::info('No discovery - The contact does not seem to speak ActivityPub.', ['id' => $contact['id'], 'url' => $url, 'network' => $contact['network']]);
205                                 return false;
206                         }
207                 }
208
209                 return true;
210         }
211
212         /**
213          * Counts all the known follows of the provided public contact
214          *
215          * @param int   $cid       Public contact id
216          * @param array $condition Additional condition on the contact table
217          * @return int
218          * @throws Exception
219          */
220         public static function countFollows(int $cid, array $condition = [])
221         {
222                 $condition = DBA::mergeConditions($condition,
223                         ['`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`)', 
224                         $cid]
225                 );
226
227                 return DI::dba()->count('contact', $condition);
228         }
229
230         /**
231          * Returns a paginated list of contacts that are followed the provided public contact.
232          *
233          * @param int   $cid       Public contact id
234          * @param array $condition Additional condition on the contact table
235          * @param int   $count
236          * @param int   $offset
237          * @param bool  $shuffle
238          * @return array
239          * @throws Exception
240          */
241         public static function listFollows(int $cid, array $condition = [], int $count = 30, int $offset = 0, bool $shuffle = false)
242         {
243                 $condition = DBA::mergeConditions($condition,
244                         ['`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`)', 
245                         $cid]
246                 );
247
248                 return DI::dba()->selectToArray('contact', [], $condition,
249                         ['limit' => [$offset, $count], 'order' => [$shuffle ? 'RAND()' : 'name']]
250                 );
251         }
252
253         /**
254          * Counts all the known followers of the provided public contact
255          *
256          * @param int   $cid       Public contact id
257          * @param array $condition Additional condition on the contact table
258          * @return int
259          * @throws Exception
260          */
261         public static function countFollowers(int $cid, array $condition = [])
262         {
263                 $condition = DBA::mergeConditions($condition,
264                         ['`id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`)',
265                         $cid]
266                 );
267
268                 return DI::dba()->count('contact', $condition);
269         }
270
271         /**
272          * Returns a paginated list of contacts that follow the provided public contact.
273          *
274          * @param int   $cid       Public contact id
275          * @param array $condition Additional condition on the contact table
276          * @param int   $count
277          * @param int   $offset
278          * @param bool  $shuffle
279          * @return array
280          * @throws Exception
281          */
282         public static function listFollowers(int $cid, array $condition = [], int $count = 30, int $offset = 0, bool $shuffle = false)
283         {
284                 $condition = DBA::mergeConditions($condition,
285                         ['`id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`)', $cid]
286                 );
287
288                 $followers = DI::dba()->selectToArray('contact', [], $condition,
289                         ['limit' => [$offset, $count], 'order' => [$shuffle ? 'RAND()' : 'name']]
290                 );
291
292                 return $followers;
293         }
294
295         /**
296          * Counts the number of contacts that both provided public contacts have interacted with at least once.
297          * Interactions include follows and likes and comments on public posts.
298          *
299          * @param int   $sourceId  Public contact id
300          * @param int   $targetId  Public contact id
301          * @param array $condition Additional condition array on the contact table
302          * @return int
303          * @throws Exception
304          */
305         public static function countCommon(int $sourceId, int $targetId, array $condition = [])
306         {
307                 $condition = DBA::mergeConditions($condition,
308                         ['`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ?) 
309                         AND `id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ?)',
310                         $sourceId, $targetId]
311                 );
312
313                 $total = DI::dba()->count('contact', $condition);
314
315                 return $total;
316         }
317
318         /**
319          * Returns a paginated list of contacts that both provided public contacts have interacted with at least once.
320          * Interactions include follows and likes and comments on public posts.
321          *
322          * @param int   $sourceId  Public contact id
323          * @param int   $targetId  Public contact id
324          * @param array $condition Additional condition on the contact table
325          * @param int   $count
326          * @param int   $offset
327          * @param bool  $shuffle
328          * @return array
329          * @throws Exception
330          */
331         public static function listCommon(int $sourceId, int $targetId, array $condition = [], int $count = 30, int $offset = 0, bool $shuffle = false)
332         {
333                 $condition = DBA::mergeConditions($condition,
334                         ["`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`) 
335                         AND `id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`)",
336                         $sourceId, $targetId]
337                 );
338
339                 $contacts = DI::dba()->selectToArray('contact', [], $condition,
340                         ['limit' => [$offset, $count], 'order' => [$shuffle ? 'name' : 'RAND()']]
341                 );
342
343                 return $contacts;
344         }
345
346
347         /**
348          * Counts the number of contacts that are followed by both provided public contacts.
349          *
350          * @param int   $sourceId  Public contact id
351          * @param int   $targetId  Public contact id
352          * @param array $condition Additional condition array on the contact table
353          * @return int
354          * @throws Exception
355          */
356         public static function countCommonFollows(int $sourceId, int $targetId, array $condition = [])
357         {
358                 $condition = DBA::mergeConditions($condition,
359                         ['`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`) 
360                         AND `id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`)',
361                         $sourceId, $targetId]
362                 );
363
364                 $total = DI::dba()->count('contact', $condition);
365
366                 return $total;
367         }
368
369         /**
370          * Returns a paginated list of contacts that are followed by both provided public contacts.
371          *
372          * @param int   $sourceId  Public contact id
373          * @param int   $targetId  Public contact id
374          * @param array $condition Additional condition array on the contact table
375          * @param int   $count
376          * @param int   $offset
377          * @param bool  $shuffle
378          * @return array
379          * @throws Exception
380          */
381         public static function listCommonFollows(int $sourceId, int $targetId, array $condition = [], int $count = 30, int $offset = 0, bool $shuffle = false)
382         {
383                 $condition = DBA::mergeConditions($condition,
384                         ["`id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`) 
385                         AND `id` IN (SELECT `relation-cid` FROM `contact-relation` WHERE `cid` = ? AND `follows`)",
386                         $sourceId, $targetId]
387                 );
388
389                 $contacts = DI::dba()->selectToArray('contact', [], $condition,
390                         ['limit' => [$offset, $count], 'order' => [$shuffle ? 'name' : 'RAND()']]
391                 );
392
393                 return $contacts;
394         }
395
396         /**
397          * Counts the number of contacts that follow both provided public contacts.
398          *
399          * @param int   $sourceId  Public contact id
400          * @param int   $targetId  Public contact id
401          * @param array $condition Additional condition on the contact table
402          * @return int
403          * @throws Exception
404          */
405         public static function countCommonFollowers(int $sourceId, int $targetId, array $condition = [])
406         {
407                 $condition = DBA::mergeConditions($condition,
408                         ["`id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`) 
409                         AND `id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`)",
410                         $sourceId, $targetId]
411                 );
412
413                 return DI::dba()->count('contact', $condition);
414         }
415
416         /**
417          * Returns a paginated list of contacts that follow both provided public contacts.
418          *
419          * @param int   $sourceId  Public contact id
420          * @param int   $targetId  Public contact id
421          * @param array $condition Additional condition on the contact table
422          * @param int   $count
423          * @param int   $offset
424          * @param bool  $shuffle
425          * @return array
426          * @throws Exception
427          */
428         public static function listCommonFollowers(int $sourceId, int $targetId, array $condition = [], int $count = 30, int $offset = 0, bool $shuffle = false)
429         {
430                 $condition = DBA::mergeConditions($condition,
431                         ["`id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`) 
432                         AND `id` IN (SELECT `cid` FROM `contact-relation` WHERE `relation-cid` = ? AND `follows`)",
433                         $sourceId, $targetId]
434                 );
435
436                 return DI::dba()->selectToArray('contact', [], $condition,
437                         ['limit' => [$offset, $count],  'order' => [$shuffle ? 'name' : 'RAND()']]
438                 );
439         }
440 }