]> git.mxchange.org Git - friendica.git/blob - src/Protocol/ActivityPub/Processor.php
move forgotten rotator from template
[friendica.git] / src / Protocol / ActivityPub / Processor.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2010-2023, the Friendica project
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\Content\Text\Markdown;
27 use Friendica\Core\Cache\Enum\Duration;
28 use Friendica\Core\Logger;
29 use Friendica\Core\Protocol;
30 use Friendica\Core\System;
31 use Friendica\Core\Worker;
32 use Friendica\Database\DBA;
33 use Friendica\DI;
34 use Friendica\Model\APContact;
35 use Friendica\Model\Contact;
36 use Friendica\Model\Conversation;
37 use Friendica\Model\Event;
38 use Friendica\Model\GServer;
39 use Friendica\Model\Item;
40 use Friendica\Model\ItemURI;
41 use Friendica\Model\Mail;
42 use Friendica\Model\Tag;
43 use Friendica\Model\User;
44 use Friendica\Model\Post;
45 use Friendica\Protocol\Activity;
46 use Friendica\Protocol\ActivityPub;
47 use Friendica\Protocol\Delivery;
48 use Friendica\Protocol\Relay;
49 use Friendica\Util\DateTimeFormat;
50 use Friendica\Util\HTTPSignature;
51 use Friendica\Util\JsonLD;
52 use Friendica\Util\Network;
53 use Friendica\Util\Strings;
54
55 /**
56  * ActivityPub Processor Protocol class
57  */
58 class Processor
59 {
60         const CACHEKEY_FETCH_ACTIVITY = 'processor:fetchMissingActivity:';
61         const CACHEKEY_JUST_FETCHED   = 'processor:isJustFetched:';
62
63         /**
64          * Add an object id to the list of processed ids
65          *
66          * @param string $id
67          *
68          * @return void
69          */
70         private static function addActivityId(string $id)
71         {
72                 DBA::delete('fetched-activity', ["`received` < ?", DateTimeFormat::utc('now - 5 minutes')]);
73                 DBA::insert('fetched-activity', ['object-id' => $id, 'received' => DateTimeFormat::utcNow()]);
74         }
75
76         /**
77          * Checks if the given object id has just been fetched
78          *
79          * @param string $id
80          *
81          * @return boolean
82          */
83         private static function isFetched(string $id): bool
84         {
85                 return DBA::exists('fetched-activity', ['object-id' => $id]);
86         }
87
88         /**
89          * Extracts the tag character (#, @, !) from mention links
90          *
91          * @param string $body
92          * @return string
93          */
94         public static function normalizeMentionLinks(string $body): string
95         {
96                 return preg_replace('%\[url=([^\[\]]*)]([#@!])(.*?)\[/url]%ism', '$2[url=$1]$3[/url]', $body);
97         }
98
99         /**
100          * Convert the language array into a language JSON
101          *
102          * @param array $languages
103          * @return string language JSON
104          */
105         private static function processLanguages(array $languages): string
106         {
107                 $codes = array_keys($languages);
108                 $lang = [];
109                 foreach ($codes as $code) {
110                         $lang[$code] = 1;
111                 }
112
113                 if (empty($lang)) {
114                         return '';
115                 }
116
117                 return json_encode($lang);
118         }
119         /**
120          * Replaces emojis in the body
121          *
122          * @param int $uri_id
123          * @param string $body
124          * @param array $emojis
125          *
126          * @return string with replaced emojis
127          */
128         private static function replaceEmojis(int $uri_id, string $body, array $emojis): string
129         {
130                 $body = strtr($body,
131                         array_combine(
132                                 array_column($emojis, 'name'),
133                                 array_map(function ($emoji) {
134                                         return '[emoji=' . $emoji['href'] . ']' . $emoji['name'] . '[/emoji]';
135                                 }, $emojis)
136                         )
137                 );
138
139                 // We store the emoji here to be able to avoid storing it in the media
140                 foreach ($emojis as $emoji) {
141                         Post\Link::getByLink($uri_id, $emoji['href']);
142                 }
143                 return $body;
144         }
145
146         /**
147          * Store attached media files in the post-media table
148          *
149          * @param int $uriid
150          * @param array $attachment
151          * @return void
152          */
153         private static function storeAttachmentAsMedia(int $uriid, array $attachment)
154         {
155                 if (empty($attachment['url'])) {
156                         return;
157                 }
158
159                 $data = ['uri-id' => $uriid];
160                 $data['type'] = Post\Media::UNKNOWN;
161                 $data['url'] = $attachment['url'];
162                 $data['mimetype'] = $attachment['mediaType'] ?? null;
163                 $data['height'] = $attachment['height'] ?? null;
164                 $data['width'] = $attachment['width'] ?? null;
165                 $data['size'] = $attachment['size'] ?? null;
166                 $data['preview'] = $attachment['image'] ?? null;
167                 $data['description'] = $attachment['name'] ?? null;
168
169                 Post\Media::insert($data);
170         }
171
172         /**
173          * Store attachment data
174          *
175          * @param array   $activity
176          * @param array   $item
177          */
178         private static function storeAttachments(array $activity, array $item)
179         {
180                 if (empty($activity['attachments'])) {
181                         return;
182                 }
183
184                 foreach ($activity['attachments'] as $attach) {
185                         self::storeAttachmentAsMedia($item['uri-id'], $attach);
186                 }
187         }
188
189         /**
190          * Store question data
191          *
192          * @param array   $activity
193          * @param array   $item
194          */
195         private static function storeQuestion(array $activity, array $item)
196         {
197                 if (empty($activity['question'])) {
198                         return;
199                 }
200                 $question = ['multiple' => $activity['question']['multiple']];
201
202                 if (!empty($activity['question']['voters'])) {
203                         $question['voters'] = $activity['question']['voters'];
204                 }
205
206                 if (!empty($activity['question']['end-time'])) {
207                         $question['end-time'] = DateTimeFormat::utc($activity['question']['end-time']);
208                 }
209
210                 Post\Question::update($item['uri-id'], $question);
211
212                 foreach ($activity['question']['options'] as $key => $option) {
213                         $option = ['name' => $option['name'], 'replies' => $option['replies']];
214                         Post\QuestionOption::update($item['uri-id'], $key, $option);
215                 }
216
217                 Logger::debug('Storing incoming question', ['type' => $activity['type'], 'uri-id' => $item['uri-id'], 'question' => $activity['question']]);
218         }
219
220         /**
221          * Updates a message
222          *
223          * @param array      $activity   Activity array
224          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
225          * @throws \ImagickException
226          */
227         public static function updateItem(array $activity)
228         {
229                 $item = Post::selectFirst(['uri', 'uri-id', 'thr-parent', 'gravity', 'post-type', 'private'], ['uri' => $activity['id']]);
230                 if (!DBA::isResult($item)) {
231                         Logger::notice('No existing item, item will be created', ['uri' => $activity['id']]);
232                         $item = self::createItem($activity, false);
233                         if (empty($item)) {
234                                 Queue::remove($activity);
235                                 return;
236                         }
237
238                         self::postItem($activity, $item);
239                         return;
240                 }
241
242                 $item['changed'] = DateTimeFormat::utcNow();
243                 $item['edited'] = DateTimeFormat::utc($activity['updated']);
244
245                 Post\Media::deleteByURIId($item['uri-id'], [Post\Media::AUDIO, Post\Media::VIDEO, Post\Media::IMAGE]);
246                 $item = self::processContent($activity, $item);
247                 if (empty($item)) {
248                         Queue::remove($activity);
249                         return;
250                 }
251
252                 self::storeAttachments($activity, $item);
253                 self::storeQuestion($activity, $item);
254
255                 Post\History::add($item['uri-id'], $item);
256                 Item::update($item, ['uri' => $activity['id']]);
257
258                 Queue::remove($activity);
259
260                 if ($activity['object_type'] == 'as:Event') {
261                         $posts = Post::select(['event-id', 'uid'], ["`uri` = ? AND `event-id` > ?", $activity['id'], 0]);
262                         while ($post = DBA::fetch($posts)) {
263                                 self::updateEvent($post['event-id'], $activity);
264                         }
265                 }
266         }
267
268         /**
269          * Update an existing event
270          *
271          * @param int $event_id
272          * @param array $activity
273          */
274         private static function updateEvent(int $event_id, array $activity)
275         {
276                 $event = DBA::selectFirst('event', [], ['id' => $event_id]);
277
278                 $event['edited']   = DateTimeFormat::utc($activity['updated']);
279                 $event['summary']  = HTML::toBBCode($activity['name']);
280                 $event['desc']     = HTML::toBBCode($activity['content']);
281                 if (!empty($activity['start-time'])) {
282                         $event['start']  = DateTimeFormat::utc($activity['start-time']);
283                 }
284                 if (!empty($activity['end-time'])) {
285                         $event['finish'] = DateTimeFormat::utc($activity['end-time']);
286                 }
287                 $event['nofinish'] = empty($event['finish']);
288                 $event['location'] = $activity['location'];
289
290                 Logger::info('Updating event', ['uri' => $activity['id'], 'id' => $event_id]);
291                 Event::store($event);
292         }
293
294         /**
295          * Prepares data for a message
296          *
297          * @param array $activity      Activity array
298          * @param bool  $fetch_parents
299          *
300          * @return array Internal item
301          *
302          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
303          * @throws \ImagickException
304          */
305         public static function createItem(array $activity, bool $fetch_parents): array
306         {
307                 $item = [];
308                 $item['verb'] = Activity::POST;
309                 $item['thr-parent'] = $activity['reply-to-id'];
310
311                 if ($activity['reply-to-id'] == $activity['id']) {
312                         $item['gravity'] = Item::GRAVITY_PARENT;
313                         $item['object-type'] = Activity\ObjectType::NOTE;
314                 } else {
315                         $item['gravity'] = Item::GRAVITY_COMMENT;
316                         $item['object-type'] = Activity\ObjectType::COMMENT;
317                 }
318
319                 if (!empty($activity['conversation'])) {
320                         $item['conversation'] = $activity['conversation'];
321                 } elseif (!empty($activity['context'])) {
322                         $item['conversation'] = $activity['context'];
323                 }
324
325                 if (!empty($item['conversation'])) {
326                         $conversation = Post::selectFirstThread(['uri'], ['conversation' => $item['conversation']]);
327                         if (!empty($conversation)) {
328                                 Logger::debug('Got conversation', ['conversation' => $item['conversation'], 'parent' => $conversation]);
329                                 $item['parent-uri'] = $conversation['uri'];
330                                 $item['parent-uri-id'] = ItemURI::getIdByURI($item['parent-uri']);
331                         }
332                 } else {
333                         $conversation = [];
334                 }
335
336                 Logger::debug('Create Item', ['id' => $activity['id'], 'conversation' => $item['conversation'] ?? '']);
337                 if (empty($activity['author']) && empty($activity['actor'])) {
338                         Logger::notice('Missing author and actor. We quit here.', ['activity' => $activity]);
339                         Queue::remove($activity);
340                         return [];
341                 }
342
343                 if (!in_array(0, $activity['receiver']) || !DI::config()->get('system', 'fetch_parents')) {
344                         $fetch_parents = false;
345                 }
346
347                 if ($fetch_parents && empty($activity['directmessage']) && ($activity['id'] != $activity['reply-to-id']) && !Post::exists(['uri' => $activity['reply-to-id']])) {
348                         $result = self::fetchParent($activity, !empty($conversation));
349                         if (!empty($result)) {
350                                 if (($item['thr-parent'] != $result) && Post::exists(['uri' => $result])) {
351                                         $item['thr-parent'] = $result;
352                                 }
353                         } elseif (empty($conversation)) {
354                                 return [];
355                         }
356                 }
357
358                 $item['diaspora_signed_text'] = $activity['diaspora:comment'] ?? '';
359
360                 if (empty($conversation) && empty($activity['directmessage']) && ($item['gravity'] != Item::GRAVITY_PARENT) && !Post::exists(['uri' => $item['thr-parent']])) {
361                         Logger::notice('Parent not found, message will be discarded.', ['thr-parent' => $item['thr-parent']]);
362                         if (!$fetch_parents) {
363                                 Queue::remove($activity);
364                         }
365                         return [];
366                 }
367
368                 $item['network'] = Protocol::ACTIVITYPUB;
369                 $item['author-link'] = $activity['author'];
370                 $item['author-id'] = Contact::getIdForURL($activity['author']);
371                 $item['owner-link'] = $activity['actor'];
372                 $item['owner-id'] = Contact::getIdForURL($activity['actor']);
373
374                 if (in_array(0, $activity['receiver']) && !empty($activity['unlisted'])) {
375                         $item['private'] = Item::UNLISTED;
376                 } elseif (in_array(0, $activity['receiver'])) {
377                         $item['private'] = Item::PUBLIC;
378                 } else {
379                         $item['private'] = Item::PRIVATE;
380                 }
381
382                 if (!empty($activity['raw'])) {
383                         $item['source'] = $activity['raw'];
384                 }
385
386                 $item['protocol'] = Conversation::PARCEL_ACTIVITYPUB;
387
388                 if (isset($activity['push'])) {
389                         $item['direction'] = $activity['push'] ? Conversation::PUSH : Conversation::PULL;
390                 }
391
392                 if (!empty($activity['from-relay'])) {
393                         $item['direction'] = Conversation::RELAY;
394                 }
395
396                 if ($activity['object_type'] == 'as:Article') {
397                         $item['post-type'] = Item::PT_ARTICLE;
398                 } elseif ($activity['object_type'] == 'as:Audio') {
399                         $item['post-type'] = Item::PT_AUDIO;
400                 } elseif ($activity['object_type'] == 'as:Document') {
401                         $item['post-type'] = Item::PT_DOCUMENT;
402                 } elseif ($activity['object_type'] == 'as:Event') {
403                         $item['post-type'] = Item::PT_EVENT;
404                 } elseif ($activity['object_type'] == 'as:Image') {
405                         $item['post-type'] = Item::PT_IMAGE;
406                 } elseif ($activity['object_type'] == 'as:Page') {
407                         $item['post-type'] = Item::PT_PAGE;
408                 } elseif ($activity['object_type'] == 'as:Question') {
409                         $item['post-type'] = Item::PT_POLL;
410                 } elseif ($activity['object_type'] == 'as:Video') {
411                         $item['post-type'] = Item::PT_VIDEO;
412                 } else {
413                         $item['post-type'] = Item::PT_NOTE;
414                 }
415
416                 $item['isForum'] = false;
417
418                 if (!empty($activity['thread-completion'])) {
419                         if ($activity['thread-completion'] != $item['owner-id']) {
420                                 $actor = Contact::getById($activity['thread-completion'], ['url']);
421                                 $item['causer-link'] = $actor['url'];
422                                 $item['causer-id'] = $activity['thread-completion'];
423                                 Logger::info('Use inherited actor as causer.', ['id' => $item['owner-id'], 'activity' => $activity['thread-completion'], 'owner' => $item['owner-link'], 'actor' => $actor['url']]);
424                         } else {
425                                 // Store the original actor in the "causer" fields to enable the check for ignored or blocked contacts
426                                 $item['causer-link'] = $item['owner-link'];
427                                 $item['causer-id']   = $item['owner-id'];
428                                 Logger::info('Use actor as causer.', ['id' => $item['owner-id'], 'actor' => $item['owner-link']]);
429                         }
430
431                         $item['owner-link'] = $item['author-link'];
432                         $item['owner-id'] = $item['author-id'];
433                 } else {
434                         $actor = APContact::getByURL($item['owner-link'], false);
435                         $item['isForum'] = ($actor['type'] ?? 'Person') == 'Group';
436                 }
437
438                 $item['uri'] = $activity['id'];
439
440                 if (empty($activity['published']) || empty($activity['updated'])) {
441                         DI::logger()->notice('published or updated keys are empty for activity', ['activity' => $activity, 'callstack' => System::callstack(10)]);
442                 }
443
444                 $item['created'] = DateTimeFormat::utc($activity['published'] ?? 'now');
445                 $item['edited'] = DateTimeFormat::utc($activity['updated'] ?? 'now');
446                 $guid = $activity['sc:identifier'] ?: self::getGUIDByURL($item['uri']);
447                 $item['guid'] = $activity['diaspora:guid'] ?: $guid;
448
449                 $item['uri-id'] = ItemURI::insert(['uri' => $item['uri'], 'guid' => $item['guid']]);
450                 if (empty($item['uri-id'])) {
451                         Logger::warning('Unable to get a uri-id for an item uri', ['uri' => $item['uri'], 'guid' => $item['guid']]);
452                         return [];
453                 }
454
455                 $item['thr-parent-id'] = ItemURI::getIdByURI($item['thr-parent']);
456
457                 $item = self::processContent($activity, $item);
458                 if (empty($item)) {
459                         Logger::info('Message was not processed');
460                         Queue::remove($activity);
461                         return [];
462                 }
463
464                 $item['plink'] = $activity['alternate-url'] ?? $item['uri'];
465
466                 self::storeAttachments($activity, $item);
467                 self::storeQuestion($activity, $item);
468
469                 // We received the post via AP, so we set the protocol of the server to AP
470                 $contact = Contact::getById($item['author-id'], ['gsid']);
471                 if (!empty($contact['gsid'])) {
472                         GServer::setProtocol($contact['gsid'], Post\DeliveryData::ACTIVITYPUB);
473                 }
474
475                 if ($item['author-id'] != $item['owner-id']) {
476                         $contact = Contact::getById($item['owner-id'], ['gsid']);
477                         if (!empty($contact['gsid'])) {
478                                 GServer::setProtocol($contact['gsid'], Post\DeliveryData::ACTIVITYPUB);
479                         }
480                 }
481
482                 return $item;
483         }
484
485         /**
486          * Fetch and process parent posts for the given activity
487          *
488          * @param array $activity
489          * @param bool  $in_background
490          *
491          * @return string
492          */
493         private static function fetchParent(array $activity, bool $in_background = false): string
494         {
495                 if (self::isFetched($activity['reply-to-id'])) {
496                         Logger::info('Id is already fetched', ['id' => $activity['reply-to-id']]);
497                         return '';
498                 }
499
500                 self::addActivityId($activity['reply-to-id']);
501
502                 if (!DI::config()->get('system', 'fetch_by_worker')) {
503                         $in_background = false;
504                 }
505
506                 $recursion_depth = $activity['recursion-depth'] ?? 0;
507
508                 if (!$in_background && ($recursion_depth < DI::config()->get('system', 'max_recursion_depth'))) {
509                         Logger::info('Parent not found. Try to refetch it.', ['parent' => $activity['reply-to-id'], 'recursion-depth' => $recursion_depth]);
510                         $result = self::fetchMissingActivity($activity['reply-to-id'], $activity, '', Receiver::COMPLETION_AUTO);
511                         if (empty($result) && self::isActivityGone($activity['reply-to-id'])) {
512                                 Logger::notice('The activity is gone, the queue entry will be deleted', ['parent' => $activity['reply-to-id']]);
513                                 if (!empty($activity['entry-id'])) {
514                                         Queue::deleteById($activity['entry-id']);
515                                 }
516                                 return '';
517                         } elseif (!empty($result)) {
518                                 $exists = Post::exists(['uri' => [$result, $activity['reply-to-id']]]);
519                                 if ($exists) {
520                                         Logger::info('The activity has been fetched and created.', ['parent' => $result]);
521                                         return $result;
522                                 } elseif (DI::config()->get('system', 'fetch_by_worker') || DI::config()->get('system', 'decoupled_receiver')) {
523                                         Logger::info('The activity has been fetched and will hopefully be created later.', ['parent' => $result]);
524                                 } else {
525                                         Logger::notice('The activity exists but has not been created, the queue entry will be deleted.', ['parent' => $result]);
526                                         if (!empty($activity['entry-id'])) {
527                                                 Queue::deleteById($activity['entry-id']);
528                                         }
529                                 }
530                                 return '';
531                         }
532                         if (empty($result) && !DI::config()->get('system', 'fetch_by_worker')) {
533                                 return '';
534                         }
535                 } elseif (self::isActivityGone($activity['reply-to-id'])) {
536                         Logger::notice('The activity is gone. We will not spawn a worker. The queue entry will be deleted', ['parent' => $activity['reply-to-id']]);
537                         if ($in_background) {
538                                 // fetching in background is done for all activities where we have got the conversation
539                                 // There we only delete the single activity and not the whole thread since we can store the
540                                 // other posts in the thread even with missing posts.
541                                 Queue::remove($activity);
542                         } elseif (!empty($activity['entry-id'])) {
543                                 Queue::deleteById($activity['entry-id']);
544                         }
545                         return '';
546                 } elseif ($in_background) {
547                         Logger::notice('Fetching is done in the background.', ['parent' => $activity['reply-to-id']]);
548                 } else {
549                         Logger::notice('Recursion level is too high.', ['parent' => $activity['reply-to-id'], 'recursion-depth' => $recursion_depth]);
550                 }
551
552                 if (!Fetch::hasWorker($activity['reply-to-id'])) {
553                         Logger::notice('Fetching is done by worker.', ['parent' => $activity['reply-to-id'], 'recursion-depth' => $recursion_depth]);
554                         Fetch::add($activity['reply-to-id']);
555                         $activity['recursion-depth'] = 0;
556                         $wid = Worker::add(Worker::PRIORITY_HIGH, 'FetchMissingActivity', $activity['reply-to-id'], $activity, '', Receiver::COMPLETION_AUTO);
557                         Fetch::setWorkerId($activity['reply-to-id'], $wid);
558                 } else {
559                         Logger::debug('Activity will already be fetched via a worker.', ['url' => $activity['reply-to-id']]);
560                 }
561
562                 return '';
563         }
564
565         /**
566          * Check if a given activity is no longer available
567          *
568          * @param string $url
569          *
570          * @return boolean
571          */
572         public static function isActivityGone(string $url): bool
573         {
574                 try {
575                         $curlResult = HTTPSignature::fetchRaw($url, 0);
576                 } catch (\Exception $exception) {
577                         Logger::notice('Error fetching url', ['url' => $url, 'exception' => $exception]);
578                         return true;
579                 }
580
581                 if (Network::isUrlBlocked($url)) {
582                         return true;
583                 }
584
585                 // @todo To ensure that the remote system is working correctly, we can check if the "Content-Type" contains JSON
586                 if (in_array($curlResult->getReturnCode(), [401, 404])) {
587                         return true;
588                 }
589
590                 if ($curlResult->isSuccess()) {
591                         $object = json_decode($curlResult->getBody(), true);
592                         if (!empty($object)) {
593                                 $activity = JsonLD::compact($object);
594                                 if (JsonLD::fetchElement($activity, '@type') == 'as:Tombstone') {
595                                         return true;
596                                 }
597                         }
598                 } elseif ($curlResult->getReturnCode() == 0) {
599                         $host = parse_url($url, PHP_URL_HOST);
600                         if (!(filter_var($host, FILTER_VALIDATE_IP) || @dns_get_record($host . '.', DNS_A + DNS_AAAA))) {
601                                 return true;
602                         }
603                 }
604
605                 return false;
606         }
607         /**
608          * Delete items
609          *
610          * @param array $activity
611          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
612          * @throws \ImagickException
613          */
614         public static function deleteItem(array $activity)
615         {
616                 $owner = Contact::getIdForURL($activity['actor']);
617
618                 Logger::info('Deleting item', ['object' => $activity['object_id'], 'owner'  => $owner]);
619                 Item::markForDeletion(['uri' => $activity['object_id'], 'owner-id' => $owner]);
620                 Queue::remove($activity);
621         }
622
623         /**
624          * Prepare the item array for an activity
625          *
626          * @param array $activity Activity array
627          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
628          * @throws \ImagickException
629          */
630         public static function addTag(array $activity)
631         {
632                 if (empty($activity['object_content']) || empty($activity['object_id'])) {
633                         return;
634                 }
635
636                 foreach ($activity['receiver'] as $receiver) {
637                         $item = Post::selectFirst(['id', 'uri-id', 'origin', 'author-link'], ['uri' => $activity['target_id'], 'uid' => $receiver]);
638                         if (!DBA::isResult($item)) {
639                                 // We don't fetch missing content for this purpose
640                                 continue;
641                         }
642
643                         if (($item['author-link'] != $activity['actor']) && !$item['origin']) {
644                                 Logger::info('Not origin, not from the author, skipping update', ['id' => $item['id'], 'author' => $item['author-link'], 'actor' => $activity['actor']]);
645                                 continue;
646                         }
647
648                         Tag::store($item['uri-id'], Tag::HASHTAG, $activity['object_content'], $activity['object_id']);
649                         Logger::info('Tagged item', ['id' => $item['id'], 'tag' => $activity['object_content'], 'uri' => $activity['target_id'], 'actor' => $activity['actor']]);
650                 }
651         }
652
653         /**
654          * Prepare the item array for an activity
655          *
656          * @param array      $activity   Activity array
657          * @param string     $verb       Activity verb
658          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
659          * @throws \ImagickException
660          */
661         public static function createActivity(array $activity, string $verb)
662         {
663                 $activity['reply-to-id'] = $activity['object_id'];
664                 $item = self::createItem($activity, false);
665                 if (empty($item)) {
666                         Logger::debug('Activity was not prepared', ['id' => $activity['object_id']]);
667                         return;
668                 }
669
670                 $item['verb'] = $verb;
671                 $item['thr-parent'] = $activity['object_id'];
672                 $item['gravity'] = Item::GRAVITY_ACTIVITY;
673                 unset($item['post-type']);
674                 $item['object-type'] = Activity\ObjectType::NOTE;
675
676                 if (!empty($activity['content'])) {
677                         $item['body'] = HTML::toBBCode($activity['content']);
678                 }
679
680                 $item['diaspora_signed_text'] = $activity['diaspora:like'] ?? '';
681
682                 self::postItem($activity, $item);
683         }
684
685         /**
686          * Fetch the Uri-Id of a post for the "featured" collection
687          *
688          * @param array $activity
689          * @return null|array
690          */
691         private static function getUriIdForFeaturedCollection(array $activity)
692         {
693                 $actor = APContact::getByURL($activity['actor']);
694                 if (empty($actor)) {
695                         return null;
696                 }
697
698                 // Refetch the account when the "featured" collection is missing.
699                 // This can be removed in a future version (end of 2022 should be good).
700                 if (empty($actor['featured'])) {
701                         $actor = APContact::getByURL($activity['actor'], true);
702                         if (empty($actor)) {
703                                 return null;
704                         }
705                 }
706
707                 $parent = Post::selectFirst(['uri-id', 'author-id'], ['uri' => $activity['object_id']]);
708                 if (empty($parent['uri-id'])) {
709                         if (self::fetchMissingActivity($activity['object_id'], $activity, '', Receiver::COMPLETION_AUTO)) {
710                                 $parent = Post::selectFirst(['uri-id'], ['uri' => $activity['object_id']]);
711                         }
712                 }
713
714                 if (!empty($parent['uri-id'])) {
715                         $parent;
716                 }
717
718                 return null;
719         }
720
721         /**
722          * Add a post to the "Featured" collection
723          *
724          * @param array $activity
725          */
726         public static function addToFeaturedCollection(array $activity)
727         {
728                 $post = self::getUriIdForFeaturedCollection($activity);
729                 if (empty($post)) {
730                         return;
731                 }
732
733                 Logger::debug('Add post to featured collection', ['post' => $post]);
734
735                 Post\Collection::add($post['uri-id'], Post\Collection::FEATURED, $post['author-id']);
736                 Queue::remove($activity);
737         }
738
739         /**
740          * Remove a post to the "Featured" collection
741          *
742          * @param array $activity
743          */
744         public static function removeFromFeaturedCollection(array $activity)
745         {
746                 $post = self::getUriIdForFeaturedCollection($activity);
747                 if (empty($post)) {
748                         return;
749                 }
750
751                 Logger::debug('Remove post from featured collection', ['post' => $post]);
752
753                 Post\Collection::remove($post['uri-id'], Post\Collection::FEATURED);
754                 Queue::remove($activity);
755         }
756
757         /**
758          * Create an event
759          *
760          * @param array $activity Activity array
761          * @param array $item
762          *
763          * @return int event id
764          * @throws \Exception
765          */
766         public static function createEvent(array $activity, array $item): int
767         {
768                 $event['summary']   = HTML::toBBCode($activity['name'] ?: $activity['summary']);
769                 $event['desc']      = HTML::toBBCode($activity['content'] ?? '');
770                 if (!empty($activity['start-time'])) {
771                         $event['start']  = DateTimeFormat::utc($activity['start-time']);
772                 }
773                 if (!empty($activity['end-time'])) {
774                         $event['finish'] = DateTimeFormat::utc($activity['end-time']);
775                 }
776                 $event['nofinish']  = empty($event['finish']);
777                 $event['location']  = $activity['location'];
778                 $event['cid']       = $item['contact-id'];
779                 $event['uid']       = $item['uid'];
780                 $event['uri']       = $item['uri'];
781                 $event['edited']    = $item['edited'];
782                 $event['private']   = $item['private'];
783                 $event['guid']      = $item['guid'];
784                 $event['plink']     = $item['plink'];
785                 $event['network']   = $item['network'];
786                 $event['protocol']  = $item['protocol'];
787                 $event['direction'] = $item['direction'];
788                 $event['source']    = $item['source'];
789
790                 $ev = DBA::selectFirst('event', ['id'], ['uri' => $item['uri'], 'uid' => $item['uid']]);
791                 if (DBA::isResult($ev)) {
792                         $event['id'] = $ev['id'];
793                 }
794
795                 $event_id = Event::store($event);
796
797                 Logger::info('Event was stored', ['id' => $event_id]);
798
799                 return $event_id;
800         }
801
802         /**
803          * Process the content
804          *
805          * @param array $activity Activity array
806          * @param array $item
807          * @return array|bool Returns the item array or false if there was an unexpected occurrence
808          * @throws \Exception
809          */
810         private static function processContent(array $activity, array $item)
811         {
812                 if (!empty($activity['mediatype']) && ($activity['mediatype'] == 'text/markdown')) {
813                         $item['title'] = strip_tags($activity['name'] ?? '');
814                         $content = Markdown::toBBCode($activity['content']);
815                 } elseif (!empty($activity['mediatype']) && ($activity['mediatype'] == 'text/bbcode')) {
816                         $item['title'] = $activity['name'];
817                         $content = $activity['content'];
818                 } else {
819                         // By default assume "text/html"
820                         $item['title'] = HTML::toBBCode($activity['name'] ?? '');
821                         $content = HTML::toBBCode($activity['content'] ?? '');
822                 }
823
824                 $item['title'] = trim(BBCode::toPlaintext($item['title']));
825
826                 if (!empty($activity['languages'])) {
827                         $item['language'] = self::processLanguages($activity['languages']);
828                 }
829
830                 if (!empty($activity['emojis'])) {
831                         $content = self::replaceEmojis($item['uri-id'], $content, $activity['emojis']);
832                 }
833
834                 $content = self::addMentionLinks($content, $activity['tags']);
835
836                 if (!empty($activity['quote-url'])) {
837                         $id = Item::fetchByLink($activity['quote-url']);
838                         if ($id) {
839                                 $shared_item = Post::selectFirst(['uri-id'], ['id' => $id]);
840                                 $item['quote-uri-id'] = $shared_item['uri-id'];
841                         } else {
842                                 Logger::info('Quote was not fetched', ['guid' => $item['guid'], 'uri-id' => $item['uri-id'], 'quote' => $activity['quote-url']]);
843                         }
844                 }
845
846                 if (!empty($activity['source'])) {
847                         $item['body'] = $activity['source'];
848                         $item['raw-body'] = $content;
849
850                         $quote_uri_id = Item::getQuoteUriId($item['body']);
851                         if (empty($item['quote-uri-id']) && !empty($quote_uri_id)) {
852                                 $item['quote-uri-id'] = $quote_uri_id;
853                         }
854
855                         $item['body'] = BBCode::removeSharedData($item['body']);
856                 } else {
857                         $parent_uri = $item['parent-uri'] ?? $item['thr-parent'];
858                         if (empty($activity['directmessage']) && ($parent_uri != $item['uri']) && ($item['gravity'] == Item::GRAVITY_COMMENT)) {
859                                 $parent = Post::selectFirst(['id', 'uri-id', 'private', 'author-link', 'alias'], ['uri' => $parent_uri]);
860                                 if (!DBA::isResult($parent)) {
861                                         Logger::warning('Unknown parent item.', ['uri' => $parent_uri]);
862                                         return false;
863                                 }
864                                 if (!empty($activity['type']) && in_array($activity['type'], Receiver::CONTENT_TYPES) && ($item['private'] == Item::PRIVATE) && ($parent['private'] != Item::PRIVATE)) {
865                                         Logger::warning('Item is private but the parent is not. Dropping.', ['item-uri' => $item['uri'], 'thr-parent' => $item['thr-parent']]);
866                                         return false;
867                                 }
868
869                                 $content = self::removeImplicitMentionsFromBody($content, $parent);
870                         }
871                         $item['content-warning'] = HTML::toBBCode($activity['summary'] ?? '');
872                         $item['raw-body'] = $item['body'] = $content;
873                 }
874
875                 self::storeFromBody($item);
876                 self::storeTags($item['uri-id'], $activity['tags']);
877
878                 self::storeReceivers($item['uri-id'], $activity['receiver_urls'] ?? []);
879
880                 $item['location'] = $activity['location'];
881
882                 if (!empty($activity['latitude']) && !empty($activity['longitude'])) {
883                         $item['coord'] = $activity['latitude'] . ' ' . $activity['longitude'];
884                 }
885
886                 $item['app'] = $activity['generator'];
887
888                 return $item;
889         }
890
891         /**
892          * Store hashtags and mentions
893          *
894          * @param array $item
895          */
896         private static function storeFromBody(array $item)
897         {
898                 // Make sure to delete all existing tags (can happen when called via the update functionality)
899                 DBA::delete('post-tag', ['uri-id' => $item['uri-id']]);
900
901                 Tag::storeFromBody($item['uri-id'], $item['body'], '@!');
902         }
903
904         /**
905          * Generate a GUID out of an URL of an ActivityPub post.
906          *
907          * @param string $url message URL
908          * @return string with GUID
909          */
910         private static function getGUIDByURL(string $url): string
911         {
912                 $parsed = parse_url($url);
913
914                 $host_hash = hash('crc32', $parsed['host']);
915
916                 unset($parsed["scheme"]);
917                 unset($parsed["host"]);
918
919                 $path = implode("/", $parsed);
920
921                 return $host_hash . '-'. hash('fnv164', $path) . '-'. hash('joaat', $path);
922         }
923
924         /**
925          * Checks if an incoming message is wanted
926          *
927          * @param array $activity
928          * @param array $item
929          * @return boolean Is the message wanted?
930          */
931         private static function isSolicitedMessage(array $activity, array $item): bool
932         {
933                 // The checks are split to improve the support when searching why a message was accepted.
934                 if (count($activity['receiver']) != 1) {
935                         // The message has more than one receiver, so it is wanted.
936                         Logger::debug('Message has got several receivers - accepted', ['uri-id' => $item['uri-id'], 'guid' => $item['guid'], 'url' => $item['uri']]);
937                         return true;
938                 }
939
940                 if ($item['private'] == Item::PRIVATE) {
941                         // We only look at public posts here. Private posts are expected to be intentionally posted to the single receiver.
942                         Logger::debug('Message is private - accepted', ['uri-id' => $item['uri-id'], 'guid' => $item['guid'], 'url' => $item['uri']]);
943                         return true;
944                 }
945
946                 if (!empty($activity['from-relay'])) {
947                         // We check relay posts at another place. When it arrived here, the message is already checked.
948                         Logger::debug('Message is a relay post that is already checked - accepted', ['uri-id' => $item['uri-id'], 'guid' => $item['guid'], 'url' => $item['uri']]);
949                         return true;
950                 }
951
952                 if (in_array($activity['completion-mode'] ?? Receiver::COMPLETION_NONE, [Receiver::COMPLETION_MANUAL, Receiver::COMPLETION_ANNOUCE])) {
953                         // Manual completions and completions caused by reshares are allowed without any further checks.
954                         Logger::debug('Message is in completion mode - accepted', ['mode' => $activity['completion-mode'], 'uri-id' => $item['uri-id'], 'guid' => $item['guid'], 'url' => $item['uri']]);
955                         return true;
956                 }
957
958                 if ($item['gravity'] != Item::GRAVITY_PARENT) {
959                         // We cannot reliably check at this point if a comment or activity belongs to an accepted post or needs to be fetched
960                         // This can possibly be improved in the future.
961                         Logger::debug('Message is no parent - accepted', ['uri-id' => $item['uri-id'], 'guid' => $item['guid'], 'url' => $item['uri']]);
962                         return true;
963                 }
964
965                 $tags = array_column(Tag::getByURIId($item['uri-id'], [Tag::HASHTAG]), 'name');
966                 if (Relay::isSolicitedPost($tags, $item['body'], $item['author-id'], $item['uri'], Protocol::ACTIVITYPUB, $activity['thread-completion'] ?? 0)) {
967                         Logger::debug('Post is accepted because of the relay settings', ['uri-id' => $item['uri-id'], 'guid' => $item['guid'], 'url' => $item['uri']]);
968                         return true;
969                 } else {
970                         return false;
971                 }
972         }
973
974         /**
975          * Creates an item post
976          *
977          * @param array $activity Activity data
978          * @param array $item     item array
979          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
980          * @throws \ImagickException
981          */
982         public static function postItem(array $activity, array $item)
983         {
984                 if (empty($item)) {
985                         return;
986                 }
987
988                 $stored = false;
989                 $success = false;
990                 ksort($activity['receiver']);
991
992                 if (!self::isSolicitedMessage($activity, $item)) {
993                         DBA::delete('item-uri', ['id' => $item['uri-id']]);
994                         if (!empty($activity['entry-id'])) {
995                                 Queue::deleteById($activity['entry-id']);
996                         }
997                         return;
998                 }
999
1000                 foreach ($activity['receiver'] as $receiver) {
1001                         if ($receiver == -1) {
1002                                 continue;
1003                         }
1004
1005                         if (($receiver != 0) && empty($item['parent-uri-id']) && !empty($item['thr-parent-id'])) {
1006                                 $parent = Post::selectFirst(['parent-uri-id', 'parent-uri'], ['uri-id' => $item['thr-parent-id'], 'uid' => [0, $receiver]]);
1007                                 if (!empty($parent['parent-uri-id'])) {
1008                                         $item['parent-uri-id'] = $parent['parent-uri-id'];
1009                                         $item['parent-uri']    = $parent['parent-uri'];
1010                                 }
1011                         }
1012
1013                         $item['uid'] = $receiver;
1014
1015                         $type = $activity['reception_type'][$receiver] ?? Receiver::TARGET_UNKNOWN;
1016                         switch($type) {
1017                                 case Receiver::TARGET_TO:
1018                                         $item['post-reason'] = Item::PR_TO;
1019                                         break;
1020                                 case Receiver::TARGET_CC:
1021                                         $item['post-reason'] = Item::PR_CC;
1022                                         break;
1023                                 case Receiver::TARGET_BTO:
1024                                         $item['post-reason'] = Item::PR_BTO;
1025                                         break;
1026                                 case Receiver::TARGET_BCC:
1027                                         $item['post-reason'] = Item::PR_BCC;
1028                                         break;
1029                                 case Receiver::TARGET_FOLLOWER:
1030                                         $item['post-reason'] = Item::PR_FOLLOWER;
1031                                         break;
1032                                 case Receiver::TARGET_ANSWER:
1033                                         $item['post-reason'] = Item::PR_COMMENT;
1034                                         break;
1035                                 case Receiver::TARGET_GLOBAL:
1036                                         $item['post-reason'] = Item::PR_GLOBAL;
1037                                         break;
1038                                 default:
1039                                         $item['post-reason'] = Item::PR_NONE;
1040                         }
1041
1042                         $item['post-reason'] = Item::getPostReason($item);
1043
1044                         if (in_array($item['post-reason'], [Item::PR_GLOBAL, Item::PR_NONE])) {
1045                                 if (!empty($activity['from-relay'])) {
1046                                         $item['post-reason'] = Item::PR_RELAY;
1047                                 } elseif (!empty($activity['thread-completion'])) {
1048                                         $item['post-reason'] = Item::PR_FETCHED;
1049                                 } elseif (!empty($activity['push'])) {
1050                                         $item['post-reason'] = Item::PR_PUSHED;
1051                                 }
1052                         } elseif (($item['post-reason'] == Item::PR_FOLLOWER) && !empty($activity['from-relay'])) {
1053                                 // When a post arrives via a relay and we follow the author, we have to override the causer.
1054                                 // Otherwise the system assumes that we follow the relay. (See "addRowInformation")
1055                                 Logger::debug('Relay post for follower', ['receiver' => $receiver, 'guid' => $item['guid'], 'relay' => $activity['from-relay']]);
1056                                 $item['causer-id'] = ($item['gravity'] == Item::GRAVITY_PARENT) ? $item['owner-id'] : $item['author-id'];
1057                         }
1058
1059                         if ($item['isForum'] ?? false) {
1060                                 $item['contact-id'] = Contact::getIdForURL($activity['actor'], $receiver);
1061                         } else {
1062                                 $item['contact-id'] = Contact::getIdForURL($activity['author'], $receiver);
1063                         }
1064
1065                         if (($receiver != 0) && empty($item['contact-id'])) {
1066                                 $item['contact-id'] = Contact::getIdForURL($activity['author']);
1067                         }
1068
1069                         if (!empty($activity['directmessage'])) {
1070                                 self::postMail($activity, $item);
1071                                 continue;
1072                         }
1073
1074                         if (($receiver != 0) && ($item['gravity'] == Item::GRAVITY_PARENT) && !in_array($item['post-reason'], [Item::PR_FOLLOWER, Item::PR_TAG, item::PR_TO, Item::PR_CC])) {
1075                                 if (!($item['isForum'] ?? false)) {
1076                                         if ($item['post-reason'] == Item::PR_BCC) {
1077                                                 Logger::info('Top level post via BCC from a non sharer, ignoring', ['uid' => $receiver, 'contact' => $item['contact-id'], 'url' => $item['uri']]);
1078                                                 continue;
1079                                         }
1080
1081                                         if ((DI::pConfig()->get($receiver, 'system', 'accept_only_sharer') != Item::COMPLETION_LIKE)
1082                                                 && in_array($activity['thread-children-type'] ?? '', Receiver::ACTIVITY_TYPES)) {
1083                                                 Logger::info('Top level post from thread completion from a non sharer had been initiated via an activity, ignoring',
1084                                                         ['type' => $activity['thread-children-type'], 'user' => $item['uid'], 'causer' => $item['causer-link'], 'author' => $activity['author'], 'url' => $item['uri']]);
1085                                                 continue;
1086                                         }
1087                                 }
1088
1089                                 $is_forum = false;
1090                                 $user = User::getById($receiver, ['account-type']);
1091                                 if (!empty($user['account-type'])) {
1092                                         $is_forum = ($user['account-type'] == User::ACCOUNT_TYPE_COMMUNITY);
1093                                 }
1094
1095                                 if ((DI::pConfig()->get($receiver, 'system', 'accept_only_sharer') == Item::COMPLETION_NONE)
1096                                         && ((!$is_forum && !($item['isForum'] ?? false) && ($activity['type'] != 'as:Announce'))
1097                                         || !Contact::isSharingByURL($activity['actor'], $receiver))) {
1098                                         Logger::info('Actor is a non sharer, is no forum or it is no announce', ['uid' => $receiver, 'actor' => $activity['actor'], 'url' => $item['uri'], 'type' => $activity['type']]);
1099                                         continue;
1100                                 }
1101
1102                                 Logger::info('Accepting post', ['uid' => $receiver, 'url' => $item['uri']]);
1103                         }
1104
1105                         if (!self::hasParents($item, $receiver)) {
1106                                 continue;
1107                         }
1108
1109                         if (($item['gravity'] != Item::GRAVITY_ACTIVITY) && ($activity['object_type'] == 'as:Event')) {
1110                                 $event_id = self::createEvent($activity, $item);
1111
1112                                 $item = Event::getItemArrayForImportedId($event_id, $item);
1113                         }
1114
1115                         $item_id = Item::insert($item);
1116                         if ($item_id) {
1117                                 Logger::info('Item insertion successful', ['user' => $item['uid'], 'item_id' => $item_id]);
1118                                 $success = true;
1119                         } else {
1120                                 Logger::notice('Item insertion aborted', ['uri' => $item['uri'], 'uid' => $item['uid']]);
1121                                 if (($item['uid'] == 0) && (count($activity['receiver']) > 1)) {
1122                                         Logger::info('Public item was aborted. We skip for all users.', ['uri' => $item['uri']]);
1123                                         break;
1124                                 }
1125                         }
1126
1127                         if ($item['uid'] == 0) {
1128                                 $stored = $item_id;
1129                         }
1130                 }
1131
1132                 Queue::remove($activity);
1133
1134                 if ($success && Queue::hasChildren($item['uri']) && Post::exists(['uri' => $item['uri']])) {
1135                         Queue::processReplyByUri($item['uri']);
1136                 }
1137
1138                 // Store send a follow request for every reshare - but only when the item had been stored
1139                 if ($stored && ($item['private'] != Item::PRIVATE) && ($item['gravity'] == Item::GRAVITY_PARENT) && !empty($item['author-link']) && ($item['author-link'] != $item['owner-link'])) {
1140                         $author = APContact::getByURL($item['owner-link'], false);
1141                         // We send automatic follow requests for reshared messages. (We don't need though for forum posts)
1142                         if ($author['type'] != 'Group') {
1143                                 Logger::info('Send follow request', ['uri' => $item['uri'], 'stored' => $stored, 'to' => $item['author-link']]);
1144                                 ActivityPub\Transmitter::sendFollowObject($item['uri'], $item['author-link']);
1145                         }
1146                 }
1147         }
1148
1149         /**
1150          * Checks if there are parent posts for the given receiver.
1151          * If not, then the system will try to add them.
1152          *
1153          * @param array $item
1154          * @param integer $receiver
1155          * @return boolean
1156          */
1157         private static function hasParents(array $item, int $receiver)
1158         {
1159                 if (($receiver == 0) || ($item['gravity'] == Item::GRAVITY_PARENT)) {
1160                         return true;
1161                 }
1162
1163                 $fields = ['causer-id' => $item['causer-id'] ?? $item['author-id'], 'post-reason' => Item::PR_FETCHED];
1164
1165                 $add_parent = true;
1166
1167                 if ($item['verb'] != Activity::ANNOUNCE) {
1168                         switch (DI::pConfig()->get($receiver, 'system', 'accept_only_sharer')) {
1169                                 case Item::COMPLETION_COMMENT:
1170                                         $add_parent = ($item['gravity'] != Item::GRAVITY_ACTIVITY);
1171                                         break;
1172
1173                                 case Item::COMPLETION_NONE:
1174                                         $add_parent = false;
1175                                         break;
1176                         }
1177                 }
1178
1179                 if ($add_parent) {
1180                         $add_parent = Contact::isSharing($fields['causer-id'], $receiver);
1181                         if (!$add_parent && ($item['author-id'] != $fields['causer-id'])) {
1182                                 $add_parent = Contact::isSharing($item['author-id'], $receiver);
1183                         }
1184                         if (!$add_parent && !in_array($item['owner-id'], [$fields['causer-id'], $item['author-id']])) {
1185                                 $add_parent = Contact::isSharing($item['owner-id'], $receiver);
1186                         }
1187                 }
1188
1189                 $has_parents = false;
1190
1191                 if (!empty($item['parent-uri-id'])) {
1192                         if (Post::exists(['uri-id' => $item['parent-uri-id'], 'uid' => $receiver])) {
1193                                 $has_parents = true;
1194                         } elseif ($add_parent && Post::exists(['uri-id' => $item['parent-uri-id'], 'uid' => 0])) {
1195                                 $stored = Item::storeForUserByUriId($item['parent-uri-id'], $receiver, $fields);
1196                                 $has_parents = (bool)$stored;
1197                                 if ($stored) {
1198                                         Logger::notice('Inserted missing parent post', ['stored' => $stored, 'uid' => $receiver, 'parent' => $item['parent-uri']]);
1199                                 } else {
1200                                         Logger::notice('Parent could not be added.', ['uid' => $receiver, 'uri' => $item['uri'], 'parent' => $item['parent-uri']]);
1201                                         return false;
1202                                 }
1203                         } elseif ($add_parent) {
1204                                 Logger::debug('Parent does not exist.', ['uid' => $receiver, 'uri' => $item['uri'], 'parent' => $item['parent-uri']]);
1205                         } else {
1206                                 Logger::debug('Parent should not be added.', ['uid' => $receiver, 'gravity' => $item['gravity'], 'verb' => $item['verb'], 'guid' => $item['guid'], 'uri' => $item['uri'], 'parent' => $item['parent-uri']]);
1207                         }
1208                 }
1209
1210                 if (empty($item['parent-uri-id']) || ($item['thr-parent-id'] != $item['parent-uri-id'])) {
1211                         if (Post::exists(['uri-id' => $item['thr-parent-id'], 'uid' => $receiver])) {
1212                                 $has_parents = true;
1213                         } elseif (($has_parents || $add_parent) && Post::exists(['uri-id' => $item['thr-parent-id'], 'uid' => 0])) {
1214                                 $stored = Item::storeForUserByUriId($item['thr-parent-id'], $receiver, $fields);
1215                                 $has_parents = $has_parents || (bool)$stored;
1216                                 if ($stored) {
1217                                         Logger::notice('Inserted missing thread parent post', ['stored' => $stored, 'uid' => $receiver, 'thread-parent' => $item['thr-parent']]);
1218                                 } else {
1219                                         Logger::notice('Thread parent could not be added.', ['uid' => $receiver, 'uri' => $item['uri'], 'thread-parent' => $item['thr-parent']]);
1220                                 }
1221                         } elseif ($add_parent) {
1222                                 Logger::debug('Thread parent does not exist.', ['uid' => $receiver, 'uri' => $item['uri'], 'thread-parent' => $item['thr-parent']]);
1223                         } else {
1224                                 Logger::debug('Thread parent should not be added.', ['uid' => $receiver, 'gravity' => $item['gravity'], 'verb' => $item['verb'], 'guid' => $item['guid'], 'uri' => $item['uri'], 'thread-parent' => $item['thr-parent']]);
1225                         }
1226                 }
1227
1228                 return $has_parents;
1229         }
1230
1231         /**
1232          * Store tags and mentions into the tag table
1233          *
1234          * @param integer $uriid
1235          * @param array $tags
1236          */
1237         private static function storeTags(int $uriid, array $tags = null)
1238         {
1239                 foreach ($tags as $tag) {
1240                         if (empty($tag['name']) || empty($tag['type']) || !in_array($tag['type'], ['Mention', 'Hashtag'])) {
1241                                 continue;
1242                         }
1243
1244                         $hash = substr($tag['name'], 0, 1);
1245
1246                         if ($tag['type'] == 'Mention') {
1247                                 if (in_array($hash, [Tag::TAG_CHARACTER[Tag::MENTION],
1248                                         Tag::TAG_CHARACTER[Tag::EXCLUSIVE_MENTION],
1249                                         Tag::TAG_CHARACTER[Tag::IMPLICIT_MENTION]])) {
1250                                         $tag['name'] = substr($tag['name'], 1);
1251                                 }
1252                                 $type = Tag::IMPLICIT_MENTION;
1253
1254                                 if (!empty($tag['href'])) {
1255                                         $apcontact = APContact::getByURL($tag['href']);
1256                                         if (!empty($apcontact['name']) || !empty($apcontact['nick'])) {
1257                                                 $tag['name'] = $apcontact['name'] ?: $apcontact['nick'];
1258                                         }
1259                                 }
1260                         } elseif ($tag['type'] == 'Hashtag') {
1261                                 if ($hash == Tag::TAG_CHARACTER[Tag::HASHTAG]) {
1262                                         $tag['name'] = substr($tag['name'], 1);
1263                                 }
1264                                 $type = Tag::HASHTAG;
1265                         }
1266
1267                         if (empty($tag['name'])) {
1268                                 continue;
1269                         }
1270
1271                         Tag::store($uriid, $type, $tag['name'], $tag['href']);
1272                 }
1273         }
1274
1275         public static function storeReceivers(int $uriid, array $receivers)
1276         {
1277                 foreach (['as:to' => Tag::TO, 'as:cc' => Tag::CC, 'as:bto' => Tag::BTO, 'as:bcc' => Tag::BCC] as $element => $type) {
1278                         if (!empty($receivers[$element])) {
1279                                 foreach ($receivers[$element] as $receiver) {
1280                                         if ($receiver == ActivityPub::PUBLIC_COLLECTION) {
1281                                                 $name = Receiver::PUBLIC_COLLECTION;
1282                                         } elseif ($path = parse_url($receiver, PHP_URL_PATH)) {
1283                                                 $name = trim($path, '/');
1284                                         } else {
1285                                                 Logger::warning('Unable to coerce name from receiver', ['receiver' => $receiver]);
1286                                                 $name = '';
1287                                         }
1288
1289                                         $target = Tag::getTargetType($receiver);
1290                                         Logger::debug('Got target type', ['type' => $target, 'url' => $receiver]);
1291                                         Tag::store($uriid, $type, $name, $receiver, $target);
1292                                 }
1293                         }
1294                 }
1295         }
1296
1297         /**
1298          * Creates an mail post
1299          *
1300          * @param array $activity Activity data
1301          * @param array $item     item array
1302          * @return int|bool New mail table row id or false on error
1303          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1304          */
1305         private static function postMail(array $activity, array $item)
1306         {
1307                 if (($item['gravity'] != Item::GRAVITY_PARENT) && !DBA::exists('mail', ['uri' => $item['thr-parent'], 'uid' => $item['uid']])) {
1308                         Logger::info('Parent not found, mail will be discarded.', ['uid' => $item['uid'], 'uri' => $item['thr-parent']]);
1309                         return false;
1310                 }
1311
1312                 Logger::info('Direct Message', $item);
1313
1314                 $msg = [];
1315                 $msg['uid'] = $item['uid'];
1316
1317                 $msg['contact-id'] = $item['contact-id'];
1318
1319                 $contact = Contact::getById($item['contact-id'], ['name', 'url', 'photo']);
1320                 $msg['from-name'] = $contact['name'];
1321                 $msg['from-url'] = $contact['url'];
1322                 $msg['from-photo'] = $contact['photo'];
1323
1324                 $msg['uri'] = $item['uri'];
1325                 $msg['created'] = $item['created'];
1326
1327                 $parent = DBA::selectFirst('mail', ['parent-uri', 'title'], ['uri' => $item['thr-parent']]);
1328                 if (DBA::isResult($parent)) {
1329                         $msg['parent-uri'] = $parent['parent-uri'];
1330                         $msg['title'] = $parent['title'];
1331                 } else {
1332                         $msg['parent-uri'] = $item['thr-parent'];
1333
1334                         if (!empty($item['title'])) {
1335                                 $msg['title'] = $item['title'];
1336                         } elseif (!empty($item['content-warning'])) {
1337                                 $msg['title'] = $item['content-warning'];
1338                         } else {
1339                                 // Trying to generate a title out of the body
1340                                 $title = $item['body'];
1341
1342                                 while (preg_match('#^(@\[url=([^\]]+)].*?\[\/url]\s)(.*)#is', $title, $matches)) {
1343                                         $title = $matches[3];
1344                                 }
1345
1346                                 $title = trim(BBCode::toPlaintext($title));
1347
1348                                 if (strlen($title) > 20) {
1349                                         $title = substr($title, 0, 20) . '...';
1350                                 }
1351
1352                                 $msg['title'] = $title;
1353                         }
1354                 }
1355                 $msg['body'] = $item['body'];
1356
1357                 return Mail::insert($msg);
1358         }
1359
1360         /**
1361          * Fetch featured posts from a contact with the given url
1362          *
1363          * @param string $url
1364          * @return void
1365          */
1366         public static function fetchFeaturedPosts(string $url)
1367         {
1368                 Logger::info('Fetch featured posts', ['contact' => $url]);
1369
1370                 $apcontact = APContact::getByURL($url);
1371                 if (empty($apcontact['featured'])) {
1372                         Logger::info('Contact does not have a featured collection', ['contact' => $url]);
1373                         return;
1374                 }
1375
1376                 $pcid = Contact::getIdForURL($url, 0, false);
1377                 if (empty($pcid)) {
1378                         Logger::notice('Contact not found', ['contact' => $url]);
1379                         return;
1380                 }
1381
1382                 $posts = Post\Collection::selectToArrayForContact($pcid, Post\Collection::FEATURED);
1383                 if (!empty($posts)) {
1384                         $old_featured = array_column($posts, 'uri-id');
1385                 } else {
1386                         $old_featured = [];
1387                 }
1388
1389                 $featured = ActivityPub::fetchItems($apcontact['featured']);
1390                 if (empty($featured)) {
1391                         Logger::info('Contact does not have featured posts', ['contact' => $url]);
1392
1393                         foreach ($old_featured as $uri_id) {
1394                                 Post\Collection::remove($uri_id, Post\Collection::FEATURED);
1395                                 Logger::debug('Removed no longer featured post', ['uri-id' => $uri_id, 'contact' => $url]);
1396                         }
1397                         return;
1398                 }
1399
1400                 $new = 0;
1401                 $old = 0;
1402
1403                 foreach ($featured as $post) {
1404                         if (empty($post['id'])) {
1405                                 continue;
1406                         }
1407                         $id = Item::fetchByLink($post['id']);
1408                         if (!empty($id)) {
1409                                 $item = Post::selectFirst(['uri-id', 'featured', 'author-id'], ['id' => $id]);
1410                                 if (!empty($item['uri-id'])) {
1411                                         if (!$item['featured']) {
1412                                                 Post\Collection::add($item['uri-id'], Post\Collection::FEATURED, $item['author-id']);
1413                                                 Logger::debug('Added featured post', ['uri-id' => $item['uri-id'], 'contact' => $url]);
1414                                                 $new++;
1415                                         } else {
1416                                                 Logger::debug('Post already had been featured', ['uri-id' => $item['uri-id'], 'contact' => $url]);
1417                                                 $old++;
1418                                         }
1419
1420                                         $index = array_search($item['uri-id'], $old_featured);
1421                                         if (!($index === false)) {
1422                                                 unset($old_featured[$index]);
1423                                         }
1424                                 }
1425                         }
1426                 }
1427
1428                 foreach ($old_featured as $uri_id) {
1429                         Post\Collection::remove($uri_id, Post\Collection::FEATURED);
1430                         Logger::debug('Removed no longer featured post', ['uri-id' => $uri_id, 'contact' => $url]);
1431                 }
1432
1433                 Logger::info('Fetched featured posts', ['new' => $new, 'old' => $old, 'contact' => $url]);
1434         }
1435
1436         public static function fetchCachedActivity(string $url, int $uid): array
1437         {
1438                 $cachekey = self::CACHEKEY_FETCH_ACTIVITY . $uid . ':' . hash('sha256', $url);
1439                 $object = DI::cache()->get($cachekey);
1440
1441                 if (!is_null($object)) {
1442                         if (!empty($object)) {
1443                                 Logger::debug('Fetch from cache', ['url' => $url, 'uid' => $uid]);
1444                         } else {
1445                                 Logger::debug('Fetch from negative cache', ['url' => $url, 'uid' => $uid]);
1446                         }
1447                         return $object;
1448                 }
1449
1450                 $object = ActivityPub::fetchContent($url, $uid);
1451                 if (empty($object)) {
1452                         Logger::notice('Activity was not fetchable, aborting.', ['url' => $url, 'uid' => $uid]);
1453                         // We perform negative caching.
1454                         DI::cache()->set($cachekey, [], Duration::FIVE_MINUTES);
1455                         return [];
1456                 }
1457
1458                 if (empty($object['id'])) {
1459                         Logger::notice('Activity has got not id, aborting. ', ['url' => $url, 'object' => $object]);
1460                         return [];
1461                 }
1462                 DI::cache()->set($cachekey, $object, Duration::FIVE_MINUTES);
1463
1464                 Logger::debug('Activity was fetched successfully', ['url' => $url, 'uid' => $uid]);
1465
1466                 return $object;
1467         }
1468
1469         /**
1470          * Fetches missing posts
1471          *
1472          * @param string     $url         message URL
1473          * @param array      $child       activity array with the child of this message
1474          * @param string     $relay_actor Relay actor
1475          * @param int        $completion  Completion mode, see Receiver::COMPLETION_*
1476          * @param int        $uid         User id that is used to fetch the activity
1477          * @return string fetched message URL
1478          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1479          * @throws \ImagickException
1480          */
1481         public static function fetchMissingActivity(string $url, array $child = [], string $relay_actor = '', int $completion = Receiver::COMPLETION_MANUAL, int $uid = 0): string
1482         {
1483                 $object = self::fetchCachedActivity($url, $uid);
1484                 if (empty($object)) {
1485                         return '';
1486                 }
1487
1488                 $signer = [];
1489
1490                 if (!empty($object['attributedTo'])) {
1491                         $attributed_to = $object['attributedTo'];
1492                         if (is_array($attributed_to)) {
1493                                 $compacted = JsonLD::compact($object);
1494                                 $attributed_to = JsonLD::fetchElement($compacted, 'as:attributedTo', '@id');
1495                         }
1496                         $signer[] = $attributed_to;
1497                 }
1498
1499                 if (!empty($object['actor'])) {
1500                         $object_actor = $object['actor'];
1501                 } elseif (!empty($attributed_to)) {
1502                         $object_actor = $attributed_to;
1503                 } else {
1504                         // Shouldn't happen
1505                         $object_actor = '';
1506                 }
1507
1508                 $signer[] = $object_actor;
1509
1510                 if (!empty($child['author'])) {
1511                         $actor = $child['author'];
1512                         $signer[] = $actor;
1513                 } else {
1514                         $actor = $object_actor;
1515                 }
1516
1517                 if (!empty($object['published'])) {
1518                         $published = $object['published'];
1519                 } elseif (!empty($child['published'])) {
1520                         $published = $child['published'];
1521                 } else {
1522                         $published = DateTimeFormat::utcNow();
1523                 }
1524
1525                 $activity = [];
1526                 $activity['@context'] = $object['@context'] ?? ActivityPub::CONTEXT;
1527                 unset($object['@context']);
1528                 $activity['id'] = $object['id'];
1529                 $activity['to'] = $object['to'] ?? [];
1530                 $activity['cc'] = $object['cc'] ?? [];
1531                 $activity['actor'] = $actor;
1532                 $activity['object'] = $object;
1533                 $activity['published'] = $published;
1534                 $activity['type'] = 'Create';
1535
1536                 $ldactivity = JsonLD::compact($activity);
1537
1538                 $ldactivity['recursion-depth'] = !empty($child['recursion-depth']) ? $child['recursion-depth'] + 1 : 0;
1539
1540                 if ($object_actor != $actor) {
1541                         Contact::updateByUrlIfNeeded($object_actor);
1542                 }
1543
1544                 Contact::updateByUrlIfNeeded($actor);
1545
1546                 if (!empty($child['thread-completion'])) {
1547                         $ldactivity['thread-completion'] = $child['thread-completion'];
1548                         $ldactivity['completion-mode']   = $child['completion-mode'] ?? Receiver::COMPLETION_NONE;
1549                 } else {
1550                         $ldactivity['thread-completion'] = Contact::getIdForURL($relay_actor ?: $actor);
1551                         $ldactivity['completion-mode']   = $completion;
1552                 }
1553
1554                 if ($completion == Receiver::COMPLETION_RELAY) {
1555                         $ldactivity['from-relay'] = $ldactivity['thread-completion'];
1556                         if (!self::acceptIncomingMessage($ldactivity, $object['id'])) {
1557                                 return '';
1558                         }
1559                 }
1560
1561                 if (!empty($child['thread-children-type'])) {
1562                         $ldactivity['thread-children-type'] = $child['thread-children-type'];
1563                 } elseif (!empty($child['type'])) {
1564                         $ldactivity['thread-children-type'] = $child['type'];
1565                 } else {
1566                         $ldactivity['thread-children-type'] = 'as:Create';
1567                 }
1568
1569                 if (($completion == Receiver::COMPLETION_RELAY) && Queue::exists($url, 'as:Create')) {
1570                         Logger::info('Activity has already been queued.', ['url' => $url, 'object' => $activity['id']]);
1571                 } elseif (ActivityPub\Receiver::processActivity($ldactivity, json_encode($activity), $uid, true, false, $signer, '', $completion)) {
1572                         Logger::info('Activity had been fetched and processed.', ['url' => $url, 'entry' => $child['entry-id'] ?? 0, 'completion' => $completion, 'object' => $activity['id']]);
1573                 } else {
1574                         Logger::info('Activity had been fetched and will be processed later.', ['url' => $url, 'entry' => $child['entry-id'] ?? 0, 'completion' => $completion, 'object' => $activity['id']]);
1575                 }
1576
1577                 return $activity['id'];
1578         }
1579
1580         /**
1581          * Test if incoming relay messages should be accepted
1582          *
1583          * @param array $activity activity array
1584          * @param string $id      object ID
1585          * @return boolean true if message is accepted
1586          */
1587         private static function acceptIncomingMessage(array $activity, string $id): bool
1588         {
1589                 if (empty($activity['as:object'])) {
1590                         Logger::info('No object field in activity - accepted', ['id' => $id]);
1591                         return true;
1592                 }
1593
1594                 $replyto = JsonLD::fetchElement($activity['as:object'], 'as:inReplyTo', '@id');
1595                 $uriid = ItemURI::getIdByURI($replyto ?? '');
1596                 if (Post::exists(['uri-id' => $uriid])) {
1597                         Logger::info('Post is a reply to an existing post - accepted', ['id' => $id, 'uri-id' => $uriid, 'replyto' => $replyto]);
1598                         return true;
1599                 }
1600
1601                 $attributed_to = JsonLD::fetchElement($activity['as:object'], 'as:attributedTo', '@id');
1602                 $authorid = Contact::getIdForURL($attributed_to);
1603
1604                 $body = HTML::toBBCode(JsonLD::fetchElement($activity['as:object'], 'as:content', '@value') ?? '');
1605
1606                 $messageTags = [];
1607                 $tags = Receiver::processTags(JsonLD::fetchElementArray($activity['as:object'], 'as:tag') ?? []);
1608                 if (!empty($tags)) {
1609                         foreach ($tags as $tag) {
1610                                 if ($tag['type'] != 'Hashtag') {
1611                                         continue;
1612                                 }
1613                                 $messageTags[] = ltrim(mb_strtolower($tag['name']), '#');
1614                         }
1615                 }
1616
1617                 return Relay::isSolicitedPost($messageTags, $body, $authorid, $id, Protocol::ACTIVITYPUB, $activity['thread-completion'] ?? 0);
1618         }
1619
1620         /**
1621          * perform a "follow" request
1622          *
1623          * @param array $activity
1624          * @return void
1625          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1626          * @throws \ImagickException
1627          */
1628         public static function followUser(array $activity)
1629         {
1630                 $uid = User::getIdForURL($activity['object_id']);
1631                 if (empty($uid)) {
1632                         Queue::remove($activity);
1633                         return;
1634                 }
1635
1636                 $owner = User::getOwnerDataById($uid);
1637                 if (empty($owner)) {
1638                         return;
1639                 }
1640
1641                 $cid = Contact::getIdForURL($activity['actor'], $uid);
1642                 if (!empty($cid)) {
1643                         self::switchContact($cid);
1644                         Contact::update(['hub-verify' => $activity['id'], 'protocol' => Protocol::ACTIVITYPUB], ['id' => $cid]);
1645                 }
1646
1647                 $item = [
1648                         'author-id' => Contact::getIdForURL($activity['actor']),
1649                         'author-link' => $activity['actor'],
1650                 ];
1651
1652                 // Ensure that the contact has got the right network type
1653                 self::switchContact($item['author-id']);
1654
1655                 $result = Contact::addRelationship($owner, [], $item, false, $activity['content'] ?? '');
1656                 if ($result === true) {
1657                         ActivityPub\Transmitter::sendContactAccept($item['author-link'], $activity['id'], $owner['uid']);
1658                 }
1659
1660                 $cid = Contact::getIdForURL($activity['actor'], $uid);
1661                 if (empty($cid)) {
1662                         return;
1663                 }
1664
1665                 if ($result && DI::config()->get('system', 'transmit_pending_events') && ($owner['contact-type'] == Contact::TYPE_COMMUNITY)) {
1666                         self::transmitPendingEvents($cid, $owner['uid']);
1667                 }
1668
1669                 if (empty($contact)) {
1670                         Contact::update(['hub-verify' => $activity['id'], 'protocol' => Protocol::ACTIVITYPUB], ['id' => $cid]);
1671                 }
1672                 Logger::notice('Follow user ' . $uid . ' from contact ' . $cid . ' with id ' . $activity['id']);
1673                 Queue::remove($activity);
1674         }
1675
1676         /**
1677          * Transmit pending events to the new follower
1678          *
1679          * @param integer $cid Contact id
1680          * @param integer $uid User id
1681          * @return void
1682          */
1683         private static function transmitPendingEvents(int $cid, int $uid)
1684         {
1685                 $account = DBA::selectFirst('account-user-view', ['ap-inbox', 'ap-sharedinbox'], ['id' => $cid]);
1686                 $inbox = $account['ap-sharedinbox'] ?: $account['ap-inbox'];
1687
1688                 $events = DBA::select('event', ['id'], ["`uid` = ? AND `start` > ? AND `type` != ?", $uid, DateTimeFormat::utcNow(), 'birthday']);
1689                 while ($event = DBA::fetch($events)) {
1690                         $post = Post::selectFirst(['id', 'uri-id', 'created'], ['event-id' => $event['id']]);
1691                         if (empty($post)) {
1692                                 continue;
1693                         }
1694                         if (DI::config()->get('system', 'bulk_delivery')) {
1695                                 Post\Delivery::add($post['uri-id'], $uid, $inbox, $post['created'], Delivery::POST, [$cid]);
1696                                 Worker::add(Worker::PRIORITY_HIGH, 'APDelivery', '', 0, $inbox, 0);
1697                         } else {
1698                                 Worker::add(Worker::PRIORITY_HIGH, 'APDelivery', Delivery::POST, $post['id'], $inbox, $uid, [$cid], $post['uri-id']);
1699                         }
1700                 }
1701         }
1702
1703         /**
1704          * Update the given profile
1705          *
1706          * @param array $activity
1707          * @throws \Exception
1708          */
1709         public static function updatePerson(array $activity)
1710         {
1711                 if (empty($activity['object_id'])) {
1712                         return;
1713                 }
1714
1715                 Logger::info('Updating profile', ['object' => $activity['object_id']]);
1716                 Contact::updateFromProbeByURL($activity['object_id']);
1717                 Queue::remove($activity);
1718         }
1719
1720         /**
1721          * Delete the given profile
1722          *
1723          * @param array $activity
1724          * @return void
1725          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1726          */
1727         public static function deletePerson(array $activity)
1728         {
1729                 if (empty($activity['object_id']) || empty($activity['actor'])) {
1730                         Logger::info('Empty object id or actor.');
1731                         Queue::remove($activity);
1732                         return;
1733                 }
1734
1735                 if ($activity['object_id'] != $activity['actor']) {
1736                         Logger::info('Object id does not match actor.');
1737                         Queue::remove($activity);
1738                         return;
1739                 }
1740
1741                 $contacts = DBA::select('contact', ['id'], ['nurl' => Strings::normaliseLink($activity['object_id'])]);
1742                 while ($contact = DBA::fetch($contacts)) {
1743                         Contact::remove($contact['id']);
1744                 }
1745                 DBA::close($contacts);
1746
1747                 Logger::info('Deleted contact', ['object' => $activity['object_id']]);
1748                 Queue::remove($activity);
1749         }
1750
1751         /**
1752          * Add moved contacts as followers for all subscribers of the old contact
1753          *
1754          * @param array $activity
1755          * @return void
1756          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1757          */
1758         public static function movePerson(array $activity)
1759         {
1760                 if (empty($activity['target_id']) || empty($activity['object_id'])) {
1761                         Queue::remove($activity);
1762                         return;
1763                 }
1764
1765                 if ($activity['object_id'] != $activity['actor']) {
1766                         Logger::notice('Object is not the actor', ['activity' => $activity]);
1767                         Queue::remove($activity);
1768                         return;
1769                 }
1770
1771                 $from = Contact::getByURL($activity['object_id'], false, ['uri-id']);
1772                 if (empty($from['uri-id'])) {
1773                         Logger::info('Object not found', ['activity' => $activity]);
1774                         Queue::remove($activity);
1775                         return;
1776                 }
1777
1778                 $contacts = DBA::select('contact', ['uid', 'url'], ["`uri-id` = ? AND `uid` != ? AND `rel` IN (?, ?)", $from['uri-id'], 0, Contact::FRIEND, Contact::SHARING]);
1779                 while ($from_contact = DBA::fetch($contacts)) {
1780                         $result = Contact::createFromProbeForUser($from_contact['uid'], $activity['target_id']);
1781                         Logger::debug('Follower added', ['from' => $from_contact, 'result' => $result]);
1782                 }
1783                 DBA::close($contacts);
1784                 Queue::remove($activity);
1785         }
1786
1787         /**
1788          * Blocks the user by the contact
1789          *
1790          * @param array $activity
1791          * @return void
1792          * @throws \Exception
1793          */
1794         public static function blockAccount(array $activity)
1795         {
1796                 $cid = Contact::getIdForURL($activity['actor']);
1797                 if (empty($cid)) {
1798                         return;
1799                 }
1800
1801                 $uid = User::getIdForURL($activity['object_id']);
1802                 if (empty($uid)) {
1803                         return;
1804                 }
1805
1806                 Contact\User::setIsBlocked($cid, $uid, true);
1807
1808                 Logger::info('Contact blocked user', ['contact' => $cid, 'user' => $uid]);
1809                 Queue::remove($activity);
1810         }
1811
1812         /**
1813          * Unblocks the user by the contact
1814          *
1815          * @param array $activity
1816          * @return void
1817          * @throws \Exception
1818          */
1819         public static function unblockAccount(array $activity)
1820         {
1821                 $cid = Contact::getIdForURL($activity['actor']);
1822                 if (empty($cid)) {
1823                         return;
1824                 }
1825
1826                 $uid = User::getIdForURL($activity['object_object']);
1827                 if (empty($uid)) {
1828                         return;
1829                 }
1830
1831                 Contact\User::setIsBlocked($cid, $uid, false);
1832
1833                 Logger::info('Contact unblocked user', ['contact' => $cid, 'user' => $uid]);
1834                 Queue::remove($activity);
1835         }
1836
1837         /**
1838          * Report a user
1839          *
1840          * @param array $activity
1841          * @return void
1842          * @throws \Exception
1843          */
1844         public static function ReportAccount(array $activity)
1845         {
1846                 $account_id = Contact::getIdForURL($activity['object_id']);
1847                 if (empty($account_id)) {
1848                         Logger::info('Unknown account', ['activity' => $activity]);
1849                         Queue::remove($activity);
1850                         return;
1851                 }
1852
1853                 $reporter_id = Contact::getIdForURL($activity['actor']);
1854                 if (empty($reporter_id)) {
1855                         Logger::info('Unknown actor', ['activity' => $activity]);
1856                         Queue::remove($activity);
1857                         return;
1858                 }
1859
1860                 $uri_ids = [];
1861                 foreach ($activity['object_ids'] as $status_id) {
1862                         $post = Post::selectFirst(['uri-id'], ['uri' => $status_id]);
1863                         if (!empty($post['uri-id'])) {
1864                                 $uri_ids[] = $post['uri-id'];
1865                         }
1866                 }
1867
1868                 $report = DI::reportFactory()->createFromReportsRequest($reporter_id, $account_id, $activity['content'], null, '', false, $uri_ids);
1869                 DI::report()->save($report);
1870
1871                 Logger::info('Stored report', ['reporter' => $reporter_id, 'account_id' => $account_id, 'comment' => $activity['content'], 'object_ids' => $activity['object_ids']]);
1872         }
1873
1874         /**
1875          * Accept a follow request
1876          *
1877          * @param array $activity
1878          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1879          * @throws \ImagickException
1880          */
1881         public static function acceptFollowUser(array $activity)
1882         {
1883                 if (!empty($activity['object_actor'])) {
1884                         $uid      = User::getIdForURL($activity['object_actor']);
1885                         $check_id = false;
1886                 } elseif (!empty($activity['receiver']) && (count($activity['receiver']) == 1)) {
1887                         $uid      = array_shift($activity['receiver']);
1888                         $check_id = true;
1889                 }
1890
1891                 if (empty($uid)) {
1892                         Logger::notice('User could not be detected', ['activity' => $activity]);
1893                         Queue::remove($activity);
1894                         return;
1895                 }
1896
1897                 $cid = Contact::getIdForURL($activity['actor'], $uid);
1898                 if (empty($cid)) {
1899                         Logger::notice('No contact found', ['actor' => $activity['actor']]);
1900                         Queue::remove($activity);
1901                         return;
1902                 }
1903
1904                 $id = Transmitter::activityIDFromContact($cid);
1905                 if ($id == $activity['object_id']) {
1906                         Logger::info('Successful id check', ['uid' => $uid, 'cid' => $cid]);
1907                 } else {
1908                         Logger::info('Unsuccessful id check', ['uid' => $uid, 'cid' => $cid, 'id' => $id, 'object_id' => $activity['object_id']]);
1909                         if ($check_id) {
1910                                 Queue::remove($activity);
1911                                 return;
1912                         }
1913                 }
1914
1915                 self::switchContact($cid);
1916
1917                 $fields = ['pending' => false];
1918
1919                 $contact = DBA::selectFirst('contact', ['rel'], ['id' => $cid]);
1920                 if ($contact['rel'] == Contact::FOLLOWER) {
1921                         $fields['rel'] = Contact::FRIEND;
1922                 }
1923
1924                 $condition = ['id' => $cid];
1925                 Contact::update($fields, $condition);
1926                 Logger::info('Accept contact request', ['contact' => $cid, 'user' => $uid]);
1927                 Queue::remove($activity);
1928         }
1929
1930         /**
1931          * Reject a follow request
1932          *
1933          * @param array $activity
1934          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1935          * @throws \ImagickException
1936          */
1937         public static function rejectFollowUser(array $activity)
1938         {
1939                 $uid = User::getIdForURL($activity['object_actor']);
1940                 if (empty($uid)) {
1941                         return;
1942                 }
1943
1944                 $cid = Contact::getIdForURL($activity['actor'], $uid);
1945                 if (empty($cid)) {
1946                         Logger::info('No contact found', ['actor' => $activity['actor']]);
1947                         return;
1948                 }
1949
1950                 self::switchContact($cid);
1951
1952                 $contact = Contact::getById($cid, ['rel']);
1953                 if ($contact['rel'] == Contact::SHARING) {
1954                         Contact::remove($cid);
1955                         Logger::info('Rejected contact request - contact removed', ['contact' => $cid, 'user' => $uid]);
1956                 } elseif ($contact['rel'] == Contact::FRIEND) {
1957                         Contact::update(['rel' => Contact::FOLLOWER], ['id' => $cid]);
1958                 } else {
1959                         Logger::info('Rejected contact request', ['contact' => $cid, 'user' => $uid]);
1960                 }
1961                 Queue::remove($activity);
1962         }
1963
1964         /**
1965          * Undo activity like "like" or "dislike"
1966          *
1967          * @param array $activity
1968          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1969          * @throws \ImagickException
1970          */
1971         public static function undoActivity(array $activity)
1972         {
1973                 if (empty($activity['object_id'])) {
1974                         return;
1975                 }
1976
1977                 if (empty($activity['object_actor'])) {
1978                         return;
1979                 }
1980
1981                 $author_id = Contact::getIdForURL($activity['object_actor']);
1982                 if (empty($author_id)) {
1983                         return;
1984                 }
1985
1986                 Item::markForDeletion(['uri' => $activity['object_id'], 'author-id' => $author_id, 'gravity' => Item::GRAVITY_ACTIVITY]);
1987                 Queue::remove($activity);
1988         }
1989
1990         /**
1991          * Activity to remove a follower
1992          *
1993          * @param array $activity
1994          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1995          * @throws \ImagickException
1996          */
1997         public static function undoFollowUser(array $activity)
1998         {
1999                 $uid = User::getIdForURL($activity['object_object']);
2000                 if (empty($uid)) {
2001                         return;
2002                 }
2003
2004                 $owner = User::getOwnerDataById($uid);
2005                 if (empty($owner)) {
2006                         return;
2007                 }
2008
2009                 $cid = Contact::getIdForURL($activity['actor'], $uid);
2010                 if (empty($cid)) {
2011                         Logger::info('No contact found', ['actor' => $activity['actor']]);
2012                         return;
2013                 }
2014
2015                 self::switchContact($cid);
2016
2017                 $contact = DBA::selectFirst('contact', [], ['id' => $cid]);
2018                 if (!DBA::isResult($contact)) {
2019                         return;
2020                 }
2021
2022                 Contact::removeFollower($contact);
2023                 Logger::info('Undo following request', ['contact' => $cid, 'user' => $uid]);
2024                 Queue::remove($activity);
2025         }
2026
2027         /**
2028          * Switches a contact to AP if needed
2029          *
2030          * @param integer $cid Contact ID
2031          * @return void
2032          * @throws \Exception
2033          */
2034         private static function switchContact(int $cid)
2035         {
2036                 $contact = DBA::selectFirst('contact', ['network', 'url'], ['id' => $cid]);
2037                 if (!DBA::isResult($contact) || in_array($contact['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN]) || Contact::isLocal($contact['url'])) {
2038                         return;
2039                 }
2040
2041                 Logger::info('Change existing contact', ['cid' => $cid, 'previous' => $contact['network']]);
2042                 Contact::updateFromProbe($cid);
2043         }
2044
2045         /**
2046          * Collects implicit mentions like:
2047          * - the author of the parent item
2048          * - all the mentioned conversants in the parent item
2049          *
2050          * @param array $parent Item array with at least ['id', 'author-link', 'alias']
2051          * @return array
2052          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
2053          */
2054         private static function getImplicitMentionList(array $parent): array
2055         {
2056                 $parent_terms = Tag::getByURIId($parent['uri-id'], [Tag::MENTION, Tag::IMPLICIT_MENTION, Tag::EXCLUSIVE_MENTION]);
2057
2058                 $parent_author = Contact::getByURL($parent['author-link'], false, ['url', 'nurl', 'alias']);
2059
2060                 $implicit_mentions = [];
2061                 if (empty($parent_author['url'])) {
2062                         Logger::notice('Author public contact unknown.', ['author-link' => $parent['author-link'], 'parent-id' => $parent['id']]);
2063                 } else {
2064                         $implicit_mentions[] = $parent_author['url'];
2065                         $implicit_mentions[] = $parent_author['nurl'];
2066                         $implicit_mentions[] = $parent_author['alias'];
2067                 }
2068
2069                 if (!empty($parent['alias'])) {
2070                         $implicit_mentions[] = $parent['alias'];
2071                 }
2072
2073                 foreach ($parent_terms as $term) {
2074                         $contact = Contact::getByURL($term['url'], false, ['url', 'nurl', 'alias']);
2075                         if (!empty($contact['url'])) {
2076                                 $implicit_mentions[] = $contact['url'];
2077                                 $implicit_mentions[] = $contact['nurl'];
2078                                 $implicit_mentions[] = $contact['alias'];
2079                         }
2080                 }
2081
2082                 return $implicit_mentions;
2083         }
2084
2085         /**
2086          * Strips from the body prepended implicit mentions
2087          *
2088          * @param string $body
2089          * @param array $parent
2090          * @return string
2091          */
2092         private static function removeImplicitMentionsFromBody(string $body, array $parent): string
2093         {
2094                 if (DI::config()->get('system', 'disable_implicit_mentions')) {
2095                         return $body;
2096                 }
2097
2098                 $potential_mentions = self::getImplicitMentionList($parent);
2099
2100                 $kept_mentions = [];
2101
2102                 // Extract one prepended mention at a time from the body
2103                 while(preg_match('#^(@\[url=([^\]]+)].*?\[\/url]\s)(.*)#is', $body, $matches)) {
2104                         if (!in_array($matches[2], $potential_mentions)) {
2105                                 $kept_mentions[] = $matches[1];
2106                         }
2107
2108                         $body = $matches[3];
2109                 }
2110
2111                 // Re-appending the kept mentions to the body after extraction
2112                 $kept_mentions[] = $body;
2113
2114                 return implode('', $kept_mentions);
2115         }
2116
2117         /**
2118          * Adds links to string mentions
2119          *
2120          * @param string $body
2121          * @param array  $tags
2122          * @return string
2123          */
2124         protected static function addMentionLinks(string $body, array $tags): string
2125         {
2126                 // This prevents links to be added again to Pleroma-style mention links
2127                 $body = self::normalizeMentionLinks($body);
2128
2129                 $body = BBCode::performWithEscapedTags($body, ['url'], function ($body) use ($tags) {
2130                         foreach ($tags as $tag) {
2131                                 if (empty($tag['name']) || empty($tag['type']) || empty($tag['href']) || !in_array($tag['type'], ['Mention', 'Hashtag'])) {
2132                                         continue;
2133                                 }
2134
2135                                 $hash = substr($tag['name'], 0, 1);
2136                                 $name = substr($tag['name'], 1);
2137                                 if (!in_array($hash, Tag::TAG_CHARACTER)) {
2138                                         $hash = '';
2139                                         $name = $tag['name'];
2140                                 }
2141
2142                                 if (Network::isValidHttpUrl($tag['href'])) {
2143                                         $body = str_replace($tag['name'], $hash . '[url=' . $tag['href'] . ']' . $name . '[/url]', $body);
2144                                 }
2145                         }
2146
2147                         return $body;
2148                 });
2149
2150                 return $body;
2151         }
2152 }