]> git.mxchange.org Git - friendica.git/blob - src/Protocol/ActivityPub/Processor.php
Merge remote-tracking branch 'upstream/develop' into write-tags
[friendica.git] / src / Protocol / ActivityPub / Processor.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\Protocol\ActivityPub;
23
24 use Friendica\Content\Text\BBCode;
25 use Friendica\Content\Text\HTML;
26 use Friendica\Core\Logger;
27 use Friendica\Core\Protocol;
28 use Friendica\Database\DBA;
29 use Friendica\DI;
30 use Friendica\Model\APContact;
31 use Friendica\Model\Contact;
32 use Friendica\Model\Conversation;
33 use Friendica\Model\Event;
34 use Friendica\Model\Item;
35 use Friendica\Model\ItemURI;
36 use Friendica\Model\Mail;
37 use Friendica\Model\Tag;
38 use Friendica\Model\User;
39 use Friendica\Protocol\Activity;
40 use Friendica\Protocol\ActivityPub;
41 use Friendica\Util\DateTimeFormat;
42 use Friendica\Util\JsonLD;
43 use Friendica\Util\Strings;
44
45 /**
46  * ActivityPub Processor Protocol class
47  */
48 class Processor
49 {
50         /**
51          * Converts mentions from Pleroma into the Friendica format
52          *
53          * @param string $body
54          *
55          * @return string converted body
56          */
57         private static function convertMentions($body)
58         {
59                 $URLSearchString = "^\[\]";
60                 $body = preg_replace("/\[url\=([$URLSearchString]*)\]([#@!])(.*?)\[\/url\]/ism", '$2[url=$1]$3[/url]', $body);
61
62                 return $body;
63         }
64
65         /**
66          * Replaces emojis in the body
67          *
68          * @param array $emojis
69          * @param string $body
70          *
71          * @return string with replaced emojis
72          */
73         private static function replaceEmojis($body, array $emojis)
74         {
75                 foreach ($emojis as $emoji) {
76                         $replace = '[class=emoji mastodon][img=' . $emoji['href'] . ']' . $emoji['name'] . '[/img][/class]';
77                         $body = str_replace($emoji['name'], $replace, $body);
78                 }
79                 return $body;
80         }
81
82         /**
83          * Constructs a string with tags for a given tag array
84          *
85          * @param array   $tags
86          * @param boolean $sensitive
87          * @return string with tags
88          */
89         private static function constructTagString(array $tags = null, $sensitive = false)
90         {
91                 if (empty($tags)) {
92                         return '';
93                 }
94
95                 $tag_text = '';
96                 foreach ($tags as $tag) {
97                         if (in_array($tag['type'] ?? '', ['Mention', 'Hashtag'])) {
98                                 if (!empty($tag_text)) {
99                                         $tag_text .= ',';
100                                 }
101
102                                 $tag_text .= substr($tag['name'], 0, 1) . '[url=' . $tag['href'] . ']' . substr($tag['name'], 1) . '[/url]';
103                         }
104                 }
105
106                 /// @todo add nsfw for $sensitive
107
108                 return $tag_text;
109         }
110
111         /**
112          * Add attachment data to the item array
113          *
114          * @param array   $activity
115          * @param array   $item
116          *
117          * @return array array
118          */
119         private static function constructAttachList($activity, $item)
120         {
121                 if (empty($activity['attachments'])) {
122                         return $item;
123                 }
124
125                 foreach ($activity['attachments'] as $attach) {
126                         $filetype = strtolower(substr($attach['mediaType'], 0, strpos($attach['mediaType'], '/')));
127                         if ($filetype == 'image') {
128                                 if (!empty($activity['source']) && strpos($activity['source'], $attach['url'])) {
129                                         continue;
130                                 }
131
132                                 if (empty($attach['name'])) {
133                                         $item['body'] .= "\n[img]" . $attach['url'] . '[/img]';
134                                 } else {
135                                         $item['body'] .= "\n[img=" . $attach['url'] . ']' . $attach['name'] . '[/img]';
136                                 }
137                         } elseif ($filetype == 'audio') {
138                                 if (!empty($activity['source']) && strpos($activity['source'], $attach['url'])) {
139                                         continue;
140                                 }
141
142                                 $item['body'] .= "\n[audio]" . $attach['url'] . '[/audio]';
143                         } elseif ($filetype == 'video') {
144                                 if (!empty($activity['source']) && strpos($activity['source'], $attach['url'])) {
145                                         continue;
146                                 }
147
148                                 $item['body'] .= "\n[video]" . $attach['url'] . '[/video]';
149                         } else {
150                                 if (!empty($item["attach"])) {
151                                         $item["attach"] .= ',';
152                                 } else {
153                                         $item["attach"] = '';
154                                 }
155                                 if (!isset($attach['length'])) {
156                                         $attach['length'] = "0";
157                                 }
158                                 $item["attach"] .= '[attach]href="'.$attach['url'].'" length="'.$attach['length'].'" type="'.$attach['mediaType'].'" title="'.($attach['name'] ?? '') .'"[/attach]';
159                         }
160                 }
161
162                 return $item;
163         }
164
165         /**
166          * Updates a message
167          *
168          * @param array $activity Activity array
169          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
170          */
171         public static function updateItem($activity)
172         {
173                 $item = Item::selectFirst(['uri', 'uri-id', 'thr-parent', 'gravity'], ['uri' => $activity['id']]);
174                 if (!DBA::isResult($item)) {
175                         Logger::warning('No existing item, item will be created', ['uri' => $activity['id']]);
176                         self::createItem($activity);
177                         return;
178                 }
179
180                 $item['changed'] = DateTimeFormat::utcNow();
181                 $item['edited'] = DateTimeFormat::utc($activity['updated']);
182
183                 $item = self::processContent($activity, $item);
184                 if (empty($item)) {
185                         return;
186                 }
187
188                 Item::update($item, ['uri' => $activity['id']]);
189         }
190
191         /**
192          * Prepares data for a message
193          *
194          * @param array $activity Activity array
195          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
196          * @throws \ImagickException
197          */
198         public static function createItem($activity)
199         {
200                 $item = [];
201                 $item['verb'] = Activity::POST;
202                 $item['thr-parent'] = $activity['reply-to-id'];
203
204                 if ($activity['reply-to-id'] == $activity['id']) {
205                         $item['gravity'] = GRAVITY_PARENT;
206                         $item['object-type'] = Activity\ObjectType::NOTE;
207                 } else {
208                         $item['gravity'] = GRAVITY_COMMENT;
209                         $item['object-type'] = Activity\ObjectType::COMMENT;
210
211                         // Ensure that the comment reaches all receivers of the referring post
212                         $activity['receiver'] = self::addReceivers($activity);
213                 }
214
215                 if (empty($activity['directmessage']) && ($activity['id'] != $activity['reply-to-id']) && !Item::exists(['uri' => $activity['reply-to-id']])) {
216                         Logger::notice('Parent not found. Try to refetch it.', ['parent' => $activity['reply-to-id']]);
217                         self::fetchMissingActivity($activity['reply-to-id'], $activity);
218                 }
219
220                 $item['diaspora_signed_text'] = $activity['diaspora:comment'] ?? '';
221
222                 self::postItem($activity, $item);
223         }
224
225         /**
226          * Delete items
227          *
228          * @param array $activity
229          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
230          * @throws \ImagickException
231          */
232         public static function deleteItem($activity)
233         {
234                 $owner = Contact::getIdForURL($activity['actor']);
235
236                 Logger::log('Deleting item ' . $activity['object_id'] . ' from ' . $owner, Logger::DEBUG);
237                 Item::markForDeletion(['uri' => $activity['object_id'], 'owner-id' => $owner]);
238         }
239
240         /**
241          * Prepare the item array for an activity
242          *
243          * @param array $activity Activity array
244          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
245          * @throws \ImagickException
246          */
247         public static function addTag($activity)
248         {
249                 if (empty($activity['object_content']) || empty($activity['object_id'])) {
250                         return;
251                 }
252
253                 foreach ($activity['receiver'] as $receiver) {
254                         $item = Item::selectFirst(['id', 'uri-id', 'tag', 'origin', 'author-link'], ['uri' => $activity['target_id'], 'uid' => $receiver]);
255                         if (!DBA::isResult($item)) {
256                                 // We don't fetch missing content for this purpose
257                                 continue;
258                         }
259
260                         if (($item['author-link'] != $activity['actor']) && !$item['origin']) {
261                                 Logger::info('Not origin, not from the author, skipping update', ['id' => $item['id'], 'author' => $item['author-link'], 'actor' => $activity['actor']]);
262                                 continue;
263                         }
264
265                         Tag::store($item['uri-id'], Tag::HASHTAG, $activity['object_content'], $activity['object_id']);
266
267                         // To-Do:
268                         // - Check if "blocktag" is set
269                         // - Check if actor is a contact
270
271                         if (!stristr($item['tag'], trim($activity['object_content']))) {
272                                 $tag = $item['tag'] . (strlen($item['tag']) ? ',' : '') . '#[url=' . $activity['object_id'] . ']'. $activity['object_content'] . '[/url]';
273                                 Item::update(['tag' => $tag], ['id' => $item['id']]);
274                                 Logger::info('Tagged item', ['id' => $item['id'], 'tag' => $activity['object_content'], 'uri' => $activity['target_id'], 'actor' => $activity['actor']]);
275                         }
276                 }
277         }
278
279         /**
280          * Add users to the receiver list of the given public activity.
281          * This is used to ensure that the activity will be stored in every thread.
282          *
283          * @param array $activity Activity array
284          * @return array Modified receiver list
285          */
286         private static function addReceivers(array $activity)
287         {
288                 if (!in_array(0, $activity['receiver'])) {
289                         // Private activities will not be modified
290                         return $activity['receiver'];
291                 }
292
293                 // Add all owners of the referring item to the receivers
294                 $original = $receivers = $activity['receiver'];
295                 $items = Item::select(['uid'], ['uri' => $activity['object_id']]);
296                 while ($item = DBA::fetch($items)) {
297                         $receivers['uid:' . $item['uid']] = $item['uid'];
298                 }
299                 DBA::close($items);
300
301                 if (count($original) != count($receivers)) {
302                         Logger::info('Improved data', ['id' => $activity['id'], 'object' => $activity['object_id'], 'original' => $original, 'improved' => $receivers]);
303                 }
304
305                 return $receivers;
306         }
307
308         /**
309          * Prepare the item array for an activity
310          *
311          * @param array  $activity Activity array
312          * @param string $verb     Activity verb
313          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
314          * @throws \ImagickException
315          */
316         public static function createActivity($activity, $verb)
317         {
318                 $item = [];
319                 $item['verb'] = $verb;
320                 $item['thr-parent'] = $activity['object_id'];
321                 $item['gravity'] = GRAVITY_ACTIVITY;
322                 $item['object-type'] = Activity\ObjectType::NOTE;
323
324                 $item['diaspora_signed_text'] = $activity['diaspora:like'] ?? '';
325
326                 $activity['receiver'] = self::addReceivers($activity);
327
328                 self::postItem($activity, $item);
329         }
330
331         /**
332          * Create an event
333          *
334          * @param array $activity Activity array
335          * @param array $item
336          * @throws \Exception
337          */
338         public static function createEvent($activity, $item)
339         {
340                 $event['summary']  = HTML::toBBCode($activity['name']);
341                 $event['desc']     = HTML::toBBCode($activity['content']);
342                 $event['start']    = $activity['start-time'];
343                 $event['finish']   = $activity['end-time'];
344                 $event['nofinish'] = empty($event['finish']);
345                 $event['location'] = $activity['location'];
346                 $event['adjust']   = true;
347                 $event['cid']      = $item['contact-id'];
348                 $event['uid']      = $item['uid'];
349                 $event['uri']      = $item['uri'];
350                 $event['edited']   = $item['edited'];
351                 $event['private']  = $item['private'];
352                 $event['guid']     = $item['guid'];
353                 $event['plink']    = $item['plink'];
354
355                 $condition = ['uri' => $item['uri'], 'uid' => $item['uid']];
356                 $ev = DBA::selectFirst('event', ['id'], $condition);
357                 if (DBA::isResult($ev)) {
358                         $event['id'] = $ev['id'];
359                 }
360
361                 $event_id = Event::store($event);
362                 Logger::log('Event '.$event_id.' was stored', Logger::DEBUG);
363         }
364
365         /**
366          * Process the content
367          *
368          * @param array $activity Activity array
369          * @param array $item
370          * @return array|bool Returns the item array or false if there was an unexpected occurrence
371          * @throws \Exception
372          */
373         private static function processContent($activity, $item)
374         {
375                 $item['title'] = HTML::toBBCode($activity['name']);
376
377                 if (!empty($activity['source'])) {
378                         $item['body'] = $activity['source'];
379                 } else {
380                         $content = HTML::toBBCode($activity['content']);
381
382                         if (!empty($activity['emojis'])) {
383                                 $content = self::replaceEmojis($content, $activity['emojis']);
384                         }
385
386                         $content = self::convertMentions($content);
387
388                         if (empty($activity['directmessage']) && ($item['thr-parent'] != $item['uri']) && ($item['gravity'] == GRAVITY_COMMENT)) {
389                                 $item_private = !in_array(0, $activity['item_receiver']);
390                                 $parent = Item::selectFirst(['id', 'private', 'author-link', 'alias'], ['uri' => $item['thr-parent']]);
391                                 if (!DBA::isResult($parent)) {
392                                         Logger::warning('Unknown parent item.', ['uri' => $item['thr-parent']]);
393                                         return false;
394                                 }
395                                 if ($item_private && ($parent['private'] == Item::PRIVATE)) {
396                                         Logger::warning('Item is private but the parent is not. Dropping.', ['item-uri' => $item['uri'], 'thr-parent' => $item['thr-parent']]);
397                                         return false;
398                                 }
399
400                                 $potential_implicit_mentions = self::getImplicitMentionList($parent);
401                                 $content = self::removeImplicitMentionsFromBody($content, $potential_implicit_mentions);
402                                 $activity['tags'] = self::convertImplicitMentionsInTags($activity['tags'], $potential_implicit_mentions);
403                         }
404                         $item['content-warning'] = HTML::toBBCode($activity['summary']);
405                         $item['body'] = $content;
406                 }
407
408                 $item['tag'] = self::constructTagString($activity['tags'], $activity['sensitive']);
409
410                 self::storeFromBody($item);
411                 self::storeTags($item['uri-id'], $activity['tags']);
412
413                 $item['location'] = $activity['location'];
414
415                 if (!empty($item['latitude']) && !empty($item['longitude'])) {
416                         $item['coord'] = $item['latitude'] . ' ' . $item['longitude'];
417                 }
418
419                 $item['app'] = $activity['generator'];
420
421                 return $item;
422         }
423
424         /**
425          * Store hashtags and mentions
426          *
427          * @param array $item
428          */
429         private static function storeFromBody(array $item)
430         {
431                 // Make sure to delete all existing tags (can happen when called via the update functionality)
432                 DBA::delete('post-tag', ['uri-id' => $item['uri-id']]);
433
434                 Tag::storeFromBody($item['uri-id'], $item['body'], '@!');
435         }
436
437         /**
438          * Generate a GUID out of an URL
439          *
440          * @param string $url message URL
441          * @return string with GUID
442          */
443         private static function getGUIDByURL(string $url)
444         {
445                 $parsed = parse_url($url);
446
447                 $host_hash = hash('crc32', $parsed['host']);
448
449                 unset($parsed["scheme"]);
450                 unset($parsed["host"]);
451
452                 $path = implode("/", $parsed);
453
454                 return $host_hash . '-'. hash('fnv164', $path) . '-'. hash('joaat', $path);
455         }
456
457         /**
458          * Creates an item post
459          *
460          * @param array $activity Activity data
461          * @param array $item     item array
462          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
463          * @throws \ImagickException
464          */
465         private static function postItem($activity, $item)
466         {
467                 /// @todo What to do with $activity['context']?
468                 if (empty($activity['directmessage']) && ($item['gravity'] != GRAVITY_PARENT) && !Item::exists(['uri' => $item['thr-parent']])) {
469                         Logger::info('Parent not found, message will be discarded.', ['thr-parent' => $item['thr-parent']]);
470                         return;
471                 }
472
473                 $item['network'] = Protocol::ACTIVITYPUB;
474                 $item['author-link'] = $activity['author'];
475                 $item['author-id'] = Contact::getIdForURL($activity['author'], 0, true);
476                 $item['owner-link'] = $activity['actor'];
477                 $item['owner-id'] = Contact::getIdForURL($activity['actor'], 0, true);
478
479                 if (in_array(0, $activity['receiver']) && !empty($activity['unlisted'])) {
480                         $item['private'] = Item::UNLISTED;
481                 } elseif (in_array(0, $activity['receiver'])) {
482                         $item['private'] = Item::PUBLIC;
483                 } else {
484                         $item['private'] = Item::PRIVATE;
485                 }
486
487                 if (!empty($activity['raw'])) {
488                         $item['source'] = $activity['raw'];
489                         $item['protocol'] = Conversation::PARCEL_ACTIVITYPUB;
490                         $item['conversation-href'] = $activity['context'] ?? '';
491                         $item['conversation-uri'] = $activity['conversation'] ?? '';
492
493                         if (isset($activity['push'])) {
494                                 $item['direction'] = $activity['push'] ? Conversation::PUSH : Conversation::PULL;
495                         }
496                 }
497
498                 $isForum = false;
499
500                 if (!empty($activity['thread-completion'])) {
501                         // Store the original actor in the "causer" fields to enable the check for ignored or blocked contacts
502                         $item['causer-link'] = $item['owner-link'];
503                         $item['causer-id'] = $item['owner-id'];
504
505                         Logger::info('Ignoring actor because of thread completion.', ['actor' => $item['owner-link']]);
506                         $item['owner-link'] = $item['author-link'];
507                         $item['owner-id'] = $item['author-id'];
508                 } else {
509                         $actor = APContact::getByURL($item['owner-link'], false);
510                         $isForum = ($actor['type'] == 'Group');
511                 }
512
513                 $item['uri'] = $activity['id'];
514
515                 $item['created'] = DateTimeFormat::utc($activity['published']);
516                 $item['edited'] = DateTimeFormat::utc($activity['updated']);
517                 $item['guid'] = $activity['diaspora:guid'] ?: $activity['sc:identifier'] ?: self::getGUIDByURL($item['uri']);
518
519                 $item['uri-id'] = ItemURI::insert(['uri' => $item['uri'], 'guid' => $item['guid']]);
520
521                 $item = self::processContent($activity, $item);
522                 if (empty($item)) {
523                         return;
524                 }
525
526                 $item['plink'] = $activity['alternate-url'] ?? $item['uri'];
527
528                 $item = self::constructAttachList($activity, $item);
529
530                 $stored = false;
531
532                 foreach ($activity['receiver'] as $receiver) {
533                         if ($receiver == -1) {
534                                 continue;
535                         }
536
537                         $item['uid'] = $receiver;
538
539                         if ($isForum) {
540                                 $item['contact-id'] = Contact::getIdForURL($activity['actor'], $receiver, true);
541                         } else {
542                                 $item['contact-id'] = Contact::getIdForURL($activity['author'], $receiver, true);
543                         }
544
545                         if (($receiver != 0) && empty($item['contact-id'])) {
546                                 $item['contact-id'] = Contact::getIdForURL($activity['author'], 0, true);
547                         }
548
549                         if (!empty($activity['directmessage'])) {
550                                 self::postMail($activity, $item);
551                                 continue;
552                         }
553
554                         if (DI::pConfig()->get($receiver, 'system', 'accept_only_sharer', false) && ($receiver != 0) && ($item['gravity'] == GRAVITY_PARENT)) {
555                                 $skip = !Contact::isSharingByURL($activity['author'], $receiver);
556
557                                 if ($skip && (($activity['type'] == 'as:Announce') || $isForum)) {
558                                         $skip = !Contact::isSharingByURL($activity['actor'], $receiver);
559                                 }
560
561                                 if ($skip) {
562                                         Logger::info('Skipping post', ['uid' => $receiver, 'url' => $item['uri']]);
563                                         continue;
564                                 }
565
566                                 Logger::info('Accepting post', ['uid' => $receiver, 'url' => $item['uri']]);
567                         }
568
569                         if (($item['gravity'] != GRAVITY_ACTIVITY) && ($activity['object_type'] == 'as:Event')) {
570                                 self::createEvent($activity, $item);
571                         }
572
573                         $item_id = Item::insert($item);
574                         if ($item_id) {
575                                 Logger::info('Item insertion successful', ['user' => $item['uid'], 'item_id' => $item_id]);
576                         } else {
577                                 Logger::notice('Item insertion aborted', ['user' => $item['uid']]);
578                         }
579
580                         if ($item['uid'] == 0) {
581                                 $stored = $item_id;
582                         }
583                 }
584
585                 // Store send a follow request for every reshare - but only when the item had been stored
586                 if ($stored && ($item['private'] != Item::PRIVATE) && ($item['gravity'] == GRAVITY_PARENT) && ($item['author-link'] != $item['owner-link'])) {
587                         $author = APContact::getByURL($item['owner-link'], false);
588                         // We send automatic follow requests for reshared messages. (We don't need though for forum posts)
589                         if ($author['type'] != 'Group') {
590                                 Logger::log('Send follow request for ' . $item['uri'] . ' (' . $stored . ') to ' . $item['author-link'], Logger::DEBUG);
591                                 ActivityPub\Transmitter::sendFollowObject($item['uri'], $item['author-link']);
592                         }
593                 }
594         }
595
596         /**
597          * Store tags and mentions into the tag table
598          *
599          * @param integer $uriid
600          * @param array $tags
601          */
602         private static function storeTags(int $uriid, array $tags = null)
603         {
604                 foreach ($tags as $tag) {
605                         if (empty($tag['name']) || empty($tag['type']) || !in_array($tag['type'], ['Mention', 'Hashtag'])) {
606                                 continue;
607                         }
608
609                         $hash = substr($tag['name'], 0, 1);
610
611                         if ($tag['type'] == 'Mention') {
612                                 if (in_array($hash, [Tag::TAG_CHARACTER[Tag::MENTION],
613                                         Tag::TAG_CHARACTER[Tag::EXCLUSIVE_MENTION],
614                                         Tag::TAG_CHARACTER[Tag::IMPLICIT_MENTION]])) {
615                                         $tag['name'] = substr($tag['name'], 1);
616                                 }
617                                 $type = Tag::IMPLICIT_MENTION;
618
619                                 if (!empty($tag['href'])) {
620                                         $apcontact = APContact::getByURL($tag['href']);
621                                         if (!empty($apcontact['name']) || !empty($apcontact['nick'])) {
622                                                 $tag['name'] = $apcontact['name'] ?: $apcontact['nick'];
623                                         }
624                                 }
625                         } elseif ($tag['type'] == 'Hashtag') {
626                                 if ($hash == Tag::TAG_CHARACTER[Tag::HASHTAG]) {
627                                         $tag['name'] = substr($tag['name'], 1);
628                                 }
629                                 $type = Tag::HASHTAG;
630                         }
631
632                         if (empty($tag['name'])) {
633                                 continue;
634                         }
635
636                         Tag::store($uriid, $type, $tag['name'], $tag['href']);
637                 }
638         }
639
640         /**
641          * Creates an mail post
642          *
643          * @param array $activity Activity data
644          * @param array $item     item array
645          * @return int|bool New mail table row id or false on error
646          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
647          */
648         private static function postMail($activity, $item)
649         {
650                 if (($item['gravity'] != GRAVITY_PARENT) && !DBA::exists('mail', ['uri' => $item['thr-parent'], 'uid' => $item['uid']])) {
651                         Logger::info('Parent not found, mail will be discarded.', ['uid' => $item['uid'], 'uri' => $item['thr-parent']]);
652                         return false;
653                 }
654
655                 Logger::info('Direct Message', $item);
656
657                 $msg = [];
658                 $msg['uid'] = $item['uid'];
659
660                 $msg['contact-id'] = $item['contact-id'];
661
662                 $contact = Contact::getById($item['contact-id'], ['name', 'url', 'photo']);
663                 $msg['from-name'] = $contact['name'];
664                 $msg['from-url'] = $contact['url'];
665                 $msg['from-photo'] = $contact['photo'];
666
667                 $msg['uri'] = $item['uri'];
668                 $msg['created'] = $item['created'];
669
670                 $parent = DBA::selectFirst('mail', ['parent-uri', 'title'], ['uri' => $item['thr-parent']]);
671                 if (DBA::isResult($parent)) {
672                         $msg['parent-uri'] = $parent['parent-uri'];
673                         $msg['title'] = $parent['title'];
674                 } else {
675                         $msg['parent-uri'] = $item['thr-parent'];
676
677                         if (!empty($item['title'])) {
678                                 $msg['title'] = $item['title'];
679                         } elseif (!empty($item['content-warning'])) {
680                                 $msg['title'] = $item['content-warning'];
681                         } else {
682                                 // Trying to generate a title out of the body
683                                 $title = $item['body'];
684
685                                 while (preg_match('#^(@\[url=([^\]]+)].*?\[\/url]\s)(.*)#is', $title, $matches)) {
686                                         $title = $matches[3];
687                                 }
688
689                                 $title = trim(HTML::toPlaintext(BBCode::convert($title, false, 2, true), 0));
690
691                                 if (strlen($title) > 20) {
692                                         $title = substr($title, 0, 20) . '...';
693                                 }
694
695                                 $msg['title'] = $title;
696                         }
697                 }
698                 $msg['body'] = $item['body'];
699
700                 return Mail::insert($msg);
701         }
702
703         /**
704          * Fetches missing posts
705          *
706          * @param string $url message URL
707          * @param array $child activity array with the child of this message
708          * @return string fetched message URL
709          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
710          */
711         public static function fetchMissingActivity($url, $child = [])
712         {
713                 if (!empty($child['receiver'])) {
714                         $uid = ActivityPub\Receiver::getFirstUserFromReceivers($child['receiver']);
715                 } else {
716                         $uid = 0;
717                 }
718
719                 $object = ActivityPub::fetchContent($url, $uid);
720                 if (empty($object)) {
721                         Logger::log('Activity ' . $url . ' was not fetchable, aborting.');
722                         return '';
723                 }
724
725                 if (empty($object['id'])) {
726                         Logger::log('Activity ' . $url . ' has got not id, aborting. ' . json_encode($object));
727                         return '';
728                 }
729
730                 if (!empty($child['author'])) {
731                         $actor = $child['author'];
732                 } elseif (!empty($object['actor'])) {
733                         $actor = $object['actor'];
734                 } elseif (!empty($object['attributedTo'])) {
735                         $actor = $object['attributedTo'];
736                 } else {
737                         // Shouldn't happen
738                         $actor = '';
739                 }
740
741                 if (!empty($object['published'])) {
742                         $published = $object['published'];
743                 } elseif (!empty($child['published'])) {
744                         $published = $child['published'];
745                 } else {
746                         $published = DateTimeFormat::utcNow();
747                 }
748
749                 $activity = [];
750                 $activity['@context'] = $object['@context'];
751                 unset($object['@context']);
752                 $activity['id'] = $object['id'];
753                 $activity['to'] = $object['to'] ?? [];
754                 $activity['cc'] = $object['cc'] ?? [];
755                 $activity['actor'] = $actor;
756                 $activity['object'] = $object;
757                 $activity['published'] = $published;
758                 $activity['type'] = 'Create';
759
760                 $ldactivity = JsonLD::compact($activity);
761
762                 $ldactivity['thread-completion'] = true;
763
764                 ActivityPub\Receiver::processActivity($ldactivity, json_encode($activity));
765
766                 Logger::notice('Activity had been fetched and processed.', ['url' => $url, 'object' => $activity['id']]);
767
768                 return $activity['id'];
769         }
770
771         /**
772          * perform a "follow" request
773          *
774          * @param array $activity
775          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
776          * @throws \ImagickException
777          */
778         public static function followUser($activity)
779         {
780                 $uid = User::getIdForURL($activity['object_id']);
781                 if (empty($uid)) {
782                         return;
783                 }
784
785                 $owner = User::getOwnerDataById($uid);
786
787                 $cid = Contact::getIdForURL($activity['actor'], $uid);
788                 if (!empty($cid)) {
789                         self::switchContact($cid);
790                         DBA::update('contact', ['hub-verify' => $activity['id'], 'protocol' => Protocol::ACTIVITYPUB], ['id' => $cid]);
791                         $contact = DBA::selectFirst('contact', [], ['id' => $cid, 'network' => Protocol::NATIVE_SUPPORT]);
792                 } else {
793                         $contact = [];
794                 }
795
796                 $item = ['author-id' => Contact::getIdForURL($activity['actor']),
797                         'author-link' => $activity['actor']];
798
799                 $note = Strings::escapeTags(trim($activity['content'] ?? ''));
800
801                 // Ensure that the contact has got the right network type
802                 self::switchContact($item['author-id']);
803
804                 $result = Contact::addRelationship($owner, $contact, $item, false, $note);
805                 if ($result === true) {
806                         ActivityPub\Transmitter::sendContactAccept($item['author-link'], $item['author-id'], $owner['uid']);
807                 }
808
809                 $cid = Contact::getIdForURL($activity['actor'], $uid);
810                 if (empty($cid)) {
811                         return;
812                 }
813
814                 if (empty($contact)) {
815                         DBA::update('contact', ['hub-verify' => $activity['id'], 'protocol' => Protocol::ACTIVITYPUB], ['id' => $cid]);
816                 }
817
818                 Logger::log('Follow user ' . $uid . ' from contact ' . $cid . ' with id ' . $activity['id']);
819         }
820
821         /**
822          * Update the given profile
823          *
824          * @param array $activity
825          * @throws \Exception
826          */
827         public static function updatePerson($activity)
828         {
829                 if (empty($activity['object_id'])) {
830                         return;
831                 }
832
833                 Logger::log('Updating profile for ' . $activity['object_id'], Logger::DEBUG);
834                 Contact::updateFromProbeByURL($activity['object_id'], true);
835         }
836
837         /**
838          * Delete the given profile
839          *
840          * @param array $activity
841          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
842          */
843         public static function deletePerson($activity)
844         {
845                 if (empty($activity['object_id']) || empty($activity['actor'])) {
846                         Logger::log('Empty object id or actor.', Logger::DEBUG);
847                         return;
848                 }
849
850                 if ($activity['object_id'] != $activity['actor']) {
851                         Logger::log('Object id does not match actor.', Logger::DEBUG);
852                         return;
853                 }
854
855                 $contacts = DBA::select('contact', ['id'], ['nurl' => Strings::normaliseLink($activity['object_id'])]);
856                 while ($contact = DBA::fetch($contacts)) {
857                         Contact::remove($contact['id']);
858                 }
859                 DBA::close($contacts);
860
861                 Logger::log('Deleted contact ' . $activity['object_id'], Logger::DEBUG);
862         }
863
864         /**
865          * Accept a follow request
866          *
867          * @param array $activity
868          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
869          * @throws \ImagickException
870          */
871         public static function acceptFollowUser($activity)
872         {
873                 $uid = User::getIdForURL($activity['object_actor']);
874                 if (empty($uid)) {
875                         return;
876                 }
877
878                 $cid = Contact::getIdForURL($activity['actor'], $uid);
879                 if (empty($cid)) {
880                         Logger::log('No contact found for ' . $activity['actor'], Logger::DEBUG);
881                         return;
882                 }
883
884                 self::switchContact($cid);
885
886                 $fields = ['pending' => false];
887
888                 $contact = DBA::selectFirst('contact', ['rel'], ['id' => $cid]);
889                 if ($contact['rel'] == Contact::FOLLOWER) {
890                         $fields['rel'] = Contact::FRIEND;
891                 }
892
893                 $condition = ['id' => $cid];
894                 DBA::update('contact', $fields, $condition);
895                 Logger::log('Accept contact request from contact ' . $cid . ' for user ' . $uid, Logger::DEBUG);
896         }
897
898         /**
899          * Reject a follow request
900          *
901          * @param array $activity
902          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
903          * @throws \ImagickException
904          */
905         public static function rejectFollowUser($activity)
906         {
907                 $uid = User::getIdForURL($activity['object_actor']);
908                 if (empty($uid)) {
909                         return;
910                 }
911
912                 $cid = Contact::getIdForURL($activity['actor'], $uid);
913                 if (empty($cid)) {
914                         Logger::log('No contact found for ' . $activity['actor'], Logger::DEBUG);
915                         return;
916                 }
917
918                 self::switchContact($cid);
919
920                 if (DBA::exists('contact', ['id' => $cid, 'rel' => Contact::SHARING])) {
921                         Contact::remove($cid);
922                         Logger::log('Rejected contact request from contact ' . $cid . ' for user ' . $uid . ' - contact had been removed.', Logger::DEBUG);
923                 } else {
924                         Logger::log('Rejected contact request from contact ' . $cid . ' for user ' . $uid . '.', Logger::DEBUG);
925                 }
926         }
927
928         /**
929          * Undo activity like "like" or "dislike"
930          *
931          * @param array $activity
932          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
933          * @throws \ImagickException
934          */
935         public static function undoActivity($activity)
936         {
937                 if (empty($activity['object_id'])) {
938                         return;
939                 }
940
941                 if (empty($activity['object_actor'])) {
942                         return;
943                 }
944
945                 $author_id = Contact::getIdForURL($activity['object_actor']);
946                 if (empty($author_id)) {
947                         return;
948                 }
949
950                 Item::markForDeletion(['uri' => $activity['object_id'], 'author-id' => $author_id, 'gravity' => GRAVITY_ACTIVITY]);
951         }
952
953         /**
954          * Activity to remove a follower
955          *
956          * @param array $activity
957          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
958          * @throws \ImagickException
959          */
960         public static function undoFollowUser($activity)
961         {
962                 $uid = User::getIdForURL($activity['object_object']);
963                 if (empty($uid)) {
964                         return;
965                 }
966
967                 $owner = User::getOwnerDataById($uid);
968
969                 $cid = Contact::getIdForURL($activity['actor'], $uid);
970                 if (empty($cid)) {
971                         Logger::log('No contact found for ' . $activity['actor'], Logger::DEBUG);
972                         return;
973                 }
974
975                 self::switchContact($cid);
976
977                 $contact = DBA::selectFirst('contact', [], ['id' => $cid]);
978                 if (!DBA::isResult($contact)) {
979                         return;
980                 }
981
982                 Contact::removeFollower($owner, $contact);
983                 Logger::log('Undo following request from contact ' . $cid . ' for user ' . $uid, Logger::DEBUG);
984         }
985
986         /**
987          * Switches a contact to AP if needed
988          *
989          * @param integer $cid Contact ID
990          * @throws \Exception
991          */
992         private static function switchContact($cid)
993         {
994                 $contact = DBA::selectFirst('contact', ['network', 'url'], ['id' => $cid]);
995                 if (!DBA::isResult($contact) || in_array($contact['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN]) || Contact::isLocal($contact['url'])) {
996                         return;
997                 }
998
999                 Logger::info('Change existing contact', ['cid' => $cid, 'previous' => $contact['network']]);
1000                 Contact::updateFromProbe($cid);
1001         }
1002
1003         /**
1004          * Collects implicit mentions like:
1005          * - the author of the parent item
1006          * - all the mentioned conversants in the parent item
1007          *
1008          * @param array $parent Item array with at least ['id', 'author-link', 'alias']
1009          * @return array
1010          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1011          */
1012         private static function getImplicitMentionList(array $parent)
1013         {
1014                 if (DI::config()->get('system', 'disable_implicit_mentions')) {
1015                         return [];
1016                 }
1017
1018                 $parent_terms = Tag::ArrayFromURIId($parent['uri-id'], [Tag::MENTION, Tag::IMPLICIT_MENTION, Tag::EXCLUSIVE_MENTION]);
1019
1020                 $parent_author = Contact::getDetailsByURL($parent['author-link'], 0);
1021
1022                 $implicit_mentions = [];
1023                 if (empty($parent_author)) {
1024                         Logger::notice('Author public contact unknown.', ['author-link' => $parent['author-link'], 'item-id' => $parent['id']]);
1025                 } else {
1026                         $implicit_mentions[] = $parent_author['url'];
1027                         $implicit_mentions[] = $parent_author['nurl'];
1028                         $implicit_mentions[] = $parent_author['alias'];
1029                 }
1030
1031                 if (!empty($parent['alias'])) {
1032                         $implicit_mentions[] = $parent['alias'];
1033                 }
1034
1035                 foreach ($parent_terms as $term) {
1036                         $contact = Contact::getDetailsByURL($term['url'], 0);
1037                         if (!empty($contact)) {
1038                                 $implicit_mentions[] = $contact['url'];
1039                                 $implicit_mentions[] = $contact['nurl'];
1040                                 $implicit_mentions[] = $contact['alias'];
1041                         }
1042                 }
1043
1044                 return $implicit_mentions;
1045         }
1046
1047         /**
1048          * Strips from the body prepended implicit mentions
1049          *
1050          * @param string $body
1051          * @param array $potential_mentions
1052          * @return string
1053          */
1054         private static function removeImplicitMentionsFromBody($body, array $potential_mentions)
1055         {
1056                 if (DI::config()->get('system', 'disable_implicit_mentions')) {
1057                         return $body;
1058                 }
1059
1060                 $kept_mentions = [];
1061
1062                 // Extract one prepended mention at a time from the body
1063                 while(preg_match('#^(@\[url=([^\]]+)].*?\[\/url]\s)(.*)#is', $body, $matches)) {
1064                         if (!in_array($matches[2], $potential_mentions)) {
1065                                 $kept_mentions[] = $matches[1];
1066                         }
1067
1068                         $body = $matches[3];
1069                 }
1070
1071                 // Re-appending the kept mentions to the body after extraction
1072                 $kept_mentions[] = $body;
1073
1074                 return implode('', $kept_mentions);
1075         }
1076
1077         private static function convertImplicitMentionsInTags($activity_tags, array $potential_mentions)
1078         {
1079                 if (DI::config()->get('system', 'disable_implicit_mentions')) {
1080                         return $activity_tags;
1081                 }
1082
1083                 foreach ($activity_tags as $index => $tag) {
1084                         if (in_array($tag['href'], $potential_mentions)) {
1085                                 $activity_tags[$index]['name'] = preg_replace(
1086                                         '/' . preg_quote(Tag::TAG_CHARACTER[Tag::MENTION], '/') . '/',
1087                                         Tag::TAG_CHARACTER[Tag::IMPLICIT_MENTION],
1088                                         $activity_tags[$index]['name'],
1089                                         1
1090                                 );
1091                         }
1092                 }
1093
1094                 return $activity_tags;
1095         }
1096 }