]> git.mxchange.org Git - friendica.git/blob - src/Protocol/ActivityPub/Receiver.php
Merge remote-tracking branch 'upstream/2023.05-rc' into quote-loop
[friendica.git] / src / Protocol / ActivityPub / Receiver.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\Database\DBA;
26 use Friendica\Content\Text\HTML;
27 use Friendica\Content\Text\Markdown;
28 use Friendica\Core\Logger;
29 use Friendica\Core\Protocol;
30 use Friendica\Core\System;
31 use Friendica\Core\Worker;
32 use Friendica\Database\Database;
33 use Friendica\DI;
34 use Friendica\Model\Contact;
35 use Friendica\Model\APContact;
36 use Friendica\Model\Item;
37 use Friendica\Model\Post;
38 use Friendica\Model\User;
39 use Friendica\Protocol\Activity;
40 use Friendica\Protocol\ActivityPub;
41 use Friendica\Util\DateTimeFormat;
42 use Friendica\Util\HTTPSignature;
43 use Friendica\Util\JsonLD;
44 use Friendica\Util\LDSignature;
45 use Friendica\Util\Network;
46 use Friendica\Util\Strings;
47
48 /**
49  * ActivityPub Receiver Protocol class
50  *
51  * To-Do:
52  * @todo Undo Announce
53  *
54  * Check what this is meant to do:
55  * - Add
56  * - Block
57  * - Flag
58  * - Remove
59  * - Undo Block
60  */
61 class Receiver
62 {
63         const PUBLIC_COLLECTION = 'as:Public';
64         const ACCOUNT_TYPES = ['as:Person', 'as:Organization', 'as:Service', 'as:Group', 'as:Application'];
65         const CONTENT_TYPES = ['as:Note', 'as:Article', 'as:Video', 'as:Image', 'as:Event', 'as:Audio', 'as:Page', 'as:Question'];
66         const ACTIVITY_TYPES = ['as:Like', 'as:Dislike', 'as:Accept', 'as:Reject', 'as:TentativeAccept', 'as:View', 'as:Read', 'litepub:EmojiReact'];
67
68         const TARGET_UNKNOWN = 0;
69         const TARGET_TO = 1;
70         const TARGET_CC = 2;
71         const TARGET_BTO = 3;
72         const TARGET_BCC = 4;
73         const TARGET_FOLLOWER = 5;
74         const TARGET_ANSWER = 6;
75         const TARGET_GLOBAL = 7;
76         const TARGET_AUDIENCE = 8;
77
78         const COMPLETION_NONE     = 0;
79         const COMPLETION_ANNOUNCE = 1;
80         const COMPLETION_RELAY    = 2;
81         const COMPLETION_MANUAL   = 3;
82         const COMPLETION_AUTO     = 4;
83
84         /**
85          * Checks incoming message from the inbox
86          *
87          * @param string  $body Body string
88          * @param array   $header Header lines
89          * @param integer $uid User ID
90          * @return void
91          * @throws \Exception
92          */
93         public static function processInbox(string $body, array $header, int $uid)
94         {
95                 $activity = json_decode($body, true);
96                 if (empty($activity)) {
97                         Logger::warning('Invalid body.');
98                         return;
99                 }
100
101                 $ldactivity = JsonLD::compact($activity);
102
103                 $actor = JsonLD::fetchElement($ldactivity, 'as:actor', '@id') ?? '';
104
105                 $apcontact = APContact::getByURL($actor);
106
107                 if (empty($apcontact)) {
108                         Logger::notice('Unable to retrieve AP contact for actor - message is discarded', ['actor' => $actor]);
109                         return;
110                 } elseif (APContact::isRelay($apcontact)) {
111                         self::processRelayPost($ldactivity, $actor);
112                         return;
113                 } else {
114                         APContact::unmarkForArchival($apcontact);
115                 }
116
117                 $sig_contact = HTTPSignature::getKeyIdContact($header);
118                 if (APContact::isRelay($sig_contact)) {
119                         Logger::info('Message from a relay', ['url' => $sig_contact['url']]);
120                         self::processRelayPost($ldactivity, $sig_contact['url']);
121                         return;
122                 }
123
124                 $http_signer = HTTPSignature::getSigner($body, $header);
125                 if ($http_signer === false) {
126                         Logger::notice('Invalid HTTP signature, message will not be trusted.', ['uid' => $uid, 'actor' => $actor, 'header' => $header, 'body' => $body]);
127                         $signer = [];
128                 } elseif (empty($http_signer)) {
129                         Logger::info('Signer is a tombstone. The message will be discarded, the signer account is deleted.');
130                         return;
131                 } else {
132                         Logger::info('Valid HTTP signature', ['signer' => $http_signer]);
133                         $signer = [$http_signer];
134                 }
135
136                 Logger::info('Message for user ' . $uid . ' is from actor ' . $actor);
137
138                 if ($http_signer === false) {
139                         $trust_source = false;
140                 } elseif (LDSignature::isSigned($activity)) {
141                         $ld_signer = LDSignature::getSigner($activity);
142                         if (empty($ld_signer)) {
143                                 Logger::info('Invalid JSON-LD signature from ' . $actor);
144                         } elseif ($ld_signer != $http_signer) {
145                                 $signer[] = $ld_signer;
146                         }
147                         if (!empty($ld_signer && ($actor == $http_signer))) {
148                                 Logger::info('The HTTP and the JSON-LD signature belong to ' . $ld_signer);
149                                 $trust_source = true;
150                         } elseif (!empty($ld_signer)) {
151                                 Logger::info('JSON-LD signature is signed by ' . $ld_signer);
152                                 $trust_source = true;
153                         } elseif ($actor == $http_signer) {
154                                 Logger::info('Bad JSON-LD signature, but HTTP signer fits the actor.');
155                                 $trust_source = true;
156                         } else {
157                                 Logger::info('Invalid JSON-LD signature and the HTTP signer is different.');
158                                 $trust_source = false;
159                         }
160                 } elseif ($actor == $http_signer) {
161                         Logger::info('Trusting post without JSON-LD signature, The actor fits the HTTP signer.');
162                         $trust_source = true;
163                 } else {
164                         Logger::info('No JSON-LD signature, different actor.');
165                         $trust_source = false;
166                 }
167
168                 self::processActivity($ldactivity, $body, $uid, $trust_source, true, $signer, $http_signer);
169         }
170
171         /**
172          * Process incoming posts from relays
173          *
174          * @param array  $activity
175          * @param string $actor
176          * @return void
177          */
178         private static function processRelayPost(array $activity, string $actor)
179         {
180                 $type = JsonLD::fetchElement($activity, '@type');
181                 if (!$type) {
182                         Logger::notice('Empty type', ['activity' => $activity, 'actor' => $actor]);
183                         return;
184                 }
185
186                 $object_type = JsonLD::fetchElement($activity, 'as:object', '@type') ?? '';
187
188                 $object_id = JsonLD::fetchElement($activity, 'as:object', '@id');
189                 if (empty($object_id)) {
190                         Logger::notice('No object id found', ['type' => $type, 'object_type' => $object_type, 'actor' => $actor, 'activity' => $activity]);
191                         return;
192                 }
193
194                 $handle = ($type == 'as:Announce');
195
196                 if (!$handle && in_array($type, ['as:Create', 'as:Update'])) {
197                         $handle = in_array($object_type, self::CONTENT_TYPES);
198                 }
199
200                 if (!$handle) {
201                         $trust_source = false;
202                         $object_data = self::prepareObjectData($activity, 0, false, $trust_source);
203
204                         if (!$trust_source) {
205                                 Logger::notice('Activity trust could not be achieved.',  ['type' => $type, 'object_type' => $object_type, 'object_id' => $object_id, 'actor' => $actor, 'activity' => $activity]);
206                                 return;
207                         }
208
209                         if (empty($object_data)) {
210                                 Logger::notice('No object data found', ['type' => $type, 'object_type' => $object_type, 'object_id' => $object_id, 'actor' => $actor, 'activity' => $activity]);
211                                 return;
212                         }
213
214                         if (self::routeActivities($object_data, $type, true)) {
215                                 Logger::debug('Handled activity', ['type' => $type, 'object_type' => $object_type, 'object_id' => $object_id, 'actor' => $actor]);
216                         } else {
217                                 Logger::info('Unhandled activity', ['type' => $type, 'object_type' => $object_type, 'object_id' => $object_id, 'actor' => $actor, 'activity' => $activity]);
218                         }
219                         return;
220                 }
221
222                 $contact = Contact::getByURL($actor);
223                 if (empty($contact)) {
224                         Logger::info('Relay contact not found', ['actor' => $actor]);
225                         return;
226                 }
227
228                 if (!in_array($contact['rel'], [Contact::SHARING, Contact::FRIEND])) {
229                         Logger::notice('Relay is no sharer', ['actor' => $actor]);
230                         return;
231                 }
232
233                 Logger::debug('Got relayed message id', ['id' => $object_id, 'actor' => $actor]);
234
235                 $item_id = Item::searchByLink($object_id);
236                 if ($item_id) {
237                         Logger::info('Relayed message already exists', ['id' => $object_id, 'item' => $item_id, 'actor' => $actor]);
238                         return;
239                 }
240
241                 $id = Processor::fetchMissingActivity($object_id, [], $actor, self::COMPLETION_RELAY);
242                 if (empty($id)) {
243                         Logger::notice('Relayed message had not been fetched', ['id' => $object_id, 'actor' => $actor]);
244                         return;
245                 }
246         }
247
248         /**
249          * Fetches the object type for a given object id
250          *
251          * @param array   $activity
252          * @param string  $object_id Object ID of the provided object
253          * @param integer $uid       User ID
254          *
255          * @return string with object type or NULL
256          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
257          * @throws \ImagickException
258          */
259         public static function fetchObjectType(array $activity, string $object_id, int $uid = 0)
260         {
261                 if (!empty($activity['as:object'])) {
262                         $object_type = JsonLD::fetchElement($activity['as:object'], '@type');
263                         if (!empty($object_type)) {
264                                 return $object_type;
265                         }
266                 }
267
268                 if (Post::exists(['uri' => $object_id, 'gravity' => [Item::GRAVITY_PARENT, Item::GRAVITY_COMMENT]])) {
269                         // We just assume "note" since it doesn't make a difference for the further processing
270                         return 'as:Note';
271                 }
272
273                 $profile = APContact::getByURL($object_id);
274                 if (!empty($profile['type'])) {
275                         APContact::unmarkForArchival($profile);
276                         return 'as:' . $profile['type'];
277                 }
278
279                 $data = Processor::fetchCachedActivity($object_id, $uid);
280                 if (!empty($data)) {
281                         $object = JsonLD::compact($data);
282                         $type = JsonLD::fetchElement($object, '@type');
283                         if (!empty($type)) {
284                                 return $type;
285                         }
286                 }
287
288                 return null;
289         }
290
291         /**
292          * Prepare the object array
293          *
294          * @param array   $activity     Array with activity data
295          * @param integer $uid          User ID
296          * @param boolean $push         Message had been pushed to our system
297          * @param boolean $trust_source Do we trust the source?
298          *
299          * @return array with object data
300          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
301          * @throws \ImagickException
302          */
303         public static function prepareObjectData(array $activity, int $uid, bool $push, bool &$trust_source): array
304         {
305                 $id        = JsonLD::fetchElement($activity, '@id');
306                 $type      = JsonLD::fetchElement($activity, '@type');
307                 $object_id = JsonLD::fetchElement($activity, 'as:object', '@id');
308
309                 if (!empty($object_id) && in_array($type, ['as:Create', 'as:Update'])) {
310                         $fetch_id = $object_id;
311                 } else {
312                         $fetch_id = $id;
313                 }
314
315                 if (!empty($activity['as:object'])) {
316                         $object_type = JsonLD::fetchElement($activity['as:object'], '@type');
317                 }
318
319                 $fetched = false;
320
321                 if (!empty($id) && !$trust_source) {
322                         $fetch_uid = $uid ?: self::getBestUserForActivity($activity);
323
324                         $fetched_activity = Processor::fetchCachedActivity($fetch_id, $fetch_uid);
325                         if (!empty($fetched_activity)) {
326                                 $fetched = true;
327                                 $object  = JsonLD::compact($fetched_activity);
328
329                                 $fetched_id   = JsonLD::fetchElement($object, '@id');
330                                 $fetched_type = JsonLD::fetchElement($object, '@type');
331
332                                 if (($fetched_id == $id) && !empty($fetched_type) && ($fetched_type == $type)) {
333                                         Logger::info('Activity had been fetched successfully', ['id' => $id]);
334                                         $trust_source = true;
335                                         $activity = $object;
336                                 } elseif (($fetched_id == $object_id) && !empty($fetched_type) && ($fetched_type == $object_type)) {
337                                         Logger::info('Fetched data is the object instead of the activity', ['id' => $id]);
338                                         $trust_source = true;
339                                         unset($object['@context']);
340                                         $activity['as:object'] = $object;
341                                 } else {
342                                         Logger::info('Activity id is not equal', ['id' => $id, 'fetched' => $fetched_id]);
343                                 }
344                         } else {
345                                 Logger::info('Activity could not been fetched', ['id' => $id]);
346                         }
347                 }
348
349                 $actor = JsonLD::fetchElement($activity, 'as:actor', '@id');
350                 if (empty($actor)) {
351                         Logger::info('Empty actor', ['activity' => $activity]);
352                         return [];
353                 }
354
355                 $type = JsonLD::fetchElement($activity, '@type');
356
357                 // Fetch all receivers from to, cc, bto and bcc
358                 $receiverdata = self::getReceivers($activity, $actor, [], false, $push || $fetched);
359                 $receivers = $reception_types = [];
360                 foreach ($receiverdata as $key => $data) {
361                         $receivers[$key] = $data['uid'];
362                         $reception_types[$data['uid']] = $data['type'] ?? self::TARGET_UNKNOWN;
363                 }
364
365                 $urls = self::getReceiverURL($activity);
366
367                 // When it is a delivery to a personal inbox we add that user to the receivers
368                 if (!empty($uid)) {
369                         $additional = [$uid => $uid];
370                         $receivers = array_replace($receivers, $additional);
371                         if (empty($activity['thread-completion']) && (empty($reception_types[$uid]) || in_array($reception_types[$uid], [self::TARGET_UNKNOWN, self::TARGET_FOLLOWER, self::TARGET_ANSWER, self::TARGET_GLOBAL]))) {
372                                 $reception_types[$uid] = self::TARGET_BCC;
373                                 $owner = User::getOwnerDataById($uid);
374                                 if (!empty($owner['url'])) {
375                                         $urls['as:bcc'][] = $owner['url'];
376                                 }
377                         }
378                 }
379
380                 // We possibly need some user to fetch private content,
381                 // so we fetch one out of the receivers if no uid is provided.
382                 $fetch_uid = $uid ?: self::getBestUserForActivity($activity);
383
384                 $object_id = JsonLD::fetchElement($activity, 'as:object', '@id');
385                 if (empty($object_id)) {
386                         Logger::info('No object found');
387                         return [];
388                 }
389
390                 if (!is_string($object_id)) {
391                         Logger::info('Invalid object id', ['object' => $object_id]);
392                         return [];
393                 }
394
395                 $object_type = self::fetchObjectType($activity, $object_id, $fetch_uid);
396
397                 // Fetch the activity on Lemmy "Announce" messages (announces of activities)
398                 if (($type == 'as:Announce') && in_array($object_type, array_merge(self::ACTIVITY_TYPES, ['as:Delete', 'as:Undo', 'as:Update']))) {
399                         Logger::debug('Fetch announced activity', ['object' => $object_id, 'uid' => $fetch_uid]);
400                         $data = Processor::fetchCachedActivity($object_id, $fetch_uid);
401                         if (!empty($data)) {
402                                 $type = $object_type;
403                                 $announced_activity = JsonLD::compact($data);
404
405                                 // Some variables need to be refetched since the activity changed
406                                 $actor = JsonLD::fetchElement($announced_activity, 'as:actor', '@id');
407                                 $announced_id = JsonLD::fetchElement($announced_activity, 'as:object', '@id');
408                                 if (empty($announced_id)) {
409                                         Logger::warning('No object id in announced activity', ['id' => $object_id, 'activity' => $activity, 'announced' => $announced_activity]);
410                                         return [];
411                                 } else {
412                                         $activity  = $announced_activity;
413                                         $object_id = $announced_id;
414                                 }
415                                 $object_type = self::fetchObjectType($activity, $object_id, $fetch_uid);
416                         }
417                 }
418
419                 // Any activities on account types must not be altered
420                 if (in_array($type, ['as:Flag'])) {
421                         $object_data = [];
422                         $object_data['id'] = JsonLD::fetchElement($activity, '@id');
423                         $object_data['object_id'] = JsonLD::fetchElement($activity, 'as:object', '@id');
424                         $object_data['object_ids'] = JsonLD::fetchElementArray($activity, 'as:object', '@id');
425                         $object_data['content'] = JsonLD::fetchElement($activity, 'as:content', '@type');
426                 } elseif (in_array($object_type, self::ACCOUNT_TYPES)) {
427                         $object_data = [];
428                         $object_data['id'] = JsonLD::fetchElement($activity, '@id');
429                         $object_data['object_id'] = JsonLD::fetchElement($activity, 'as:object', '@id');
430                         $object_data['object_actor'] = JsonLD::fetchElement($activity['as:object'], 'as:actor', '@id');
431                         $object_data['object_object'] = JsonLD::fetchElement($activity['as:object'], 'as:object');
432                         $object_data['object_type'] = JsonLD::fetchElement($activity['as:object'], '@type');
433                         if (!$trust_source && ($type == 'as:Delete')) {
434                                 $apcontact = APContact::getByURL($object_data['object_id'], true);
435                                 $trust_source = empty($apcontact) || ($apcontact['type'] == 'Tombstone') || $apcontact['suspended'];
436                         }
437                 } elseif (in_array($type, ['as:Create', 'as:Update', 'as:Invite']) || strpos($type, '#emojiReaction')) {
438                         // Fetch the content only on activities where this matters
439                         // We can receive "#emojiReaction" when fetching content from Hubzilla systems
440                         $object_data = self::fetchObject($object_id, $activity['as:object'], $trust_source, $fetch_uid);
441                         if (empty($object_data)) {
442                                 Logger::info("Object data couldn't be processed");
443                                 return [];
444                         }
445
446                         $object_data['object_id'] = $object_id;
447
448                         // Test if it is an answer to a mail
449                         if (DBA::exists('mail', ['uri' => $object_data['reply-to-id']])) {
450                                 $object_data['directmessage'] = true;
451                         } else {
452                                 $object_data['directmessage'] = JsonLD::fetchElement($activity, 'litepub:directMessage');
453                         }
454                 } elseif (in_array($type, array_merge(self::ACTIVITY_TYPES, ['as:Announce', 'as:Follow'])) && in_array($object_type, self::CONTENT_TYPES)) {
455                         // Create a mostly empty array out of the activity data (instead of the object).
456                         // This way we later don't have to check for the existence of each individual array element.
457                         $object_data = self::processObject($activity);
458                         $object_data['name'] = $type;
459                         $object_data['author'] = JsonLD::fetchElement($activity, 'as:actor', '@id');
460                         $object_data['object_id'] = $object_id;
461                         $object_data['object_type'] = ''; // Since we don't fetch the object, we don't know the type
462                 } elseif (in_array($type, ['as:Add', 'as:Remove', 'as:Move'])) {
463                         $object_data = [];
464                         $object_data['id'] = JsonLD::fetchElement($activity, '@id');
465                         $object_data['target_id'] = JsonLD::fetchElement($activity, 'as:target', '@id');
466                         $object_data['object_id'] = JsonLD::fetchElement($activity, 'as:object', '@id');
467                         $object_data['object_type'] = JsonLD::fetchElement($activity['as:object'], '@type');
468                         $object_data['object_content'] = JsonLD::fetchElement($activity['as:object'], 'as:content', '@type');
469                 } else {
470                         $object_data = [];
471                         $object_data['id'] = JsonLD::fetchElement($activity, '@id');
472                         $object_data['object_id'] = JsonLD::fetchElement($activity, 'as:object', '@id');
473                         $object_data['object_actor'] = JsonLD::fetchElement($activity['as:object'], 'as:actor', '@id');
474                         $object_data['object_object'] = JsonLD::fetchElement($activity['as:object'], 'as:object');
475                         $object_data['object_type'] = JsonLD::fetchElement($activity['as:object'], '@type');
476
477                         // An Undo is done on the object of an object, so we need that type as well
478                         if (($type == 'as:Undo') && !empty($object_data['object_object'])) {
479                                 $object_data['object_object_type'] = self::fetchObjectType([], $object_data['object_object'], $fetch_uid);
480                         }
481
482                         if (!$trust_source && ($type == 'as:Delete') && in_array($object_data['object_type'], array_merge(['as:Tombstone', ''], self::CONTENT_TYPES))) {
483                                 $trust_source = Processor::isActivityGone($object_data['object_id']);
484                                 if (!$trust_source) {
485                                         $trust_source = !empty(APContact::getByURL($object_data['object_id'], false));
486                                 }
487                         }
488                 }
489
490                 $object_data['push'] = $push;
491
492                 $object_data = self::addActivityFields($object_data, $activity);
493
494                 if (empty($object_data['object_type'])) {
495                         $object_data['object_type'] = $object_type;
496                 }
497
498                 foreach (['as:to', 'as:cc', 'as:bto', 'as:bcc', 'as:audience', 'as:attributedTo'] as $element) {
499                         if ((empty($object_data['receiver_urls'][$element]) || in_array($element, ['as:bto', 'as:bcc'])) && !empty($urls[$element])) {
500                                 $object_data['receiver_urls'][$element] = array_unique(array_merge($object_data['receiver_urls'][$element] ?? [], $urls[$element]));
501                         }
502                 }
503
504                 $object_data['type'] = $type;
505                 $object_data['actor'] = $actor;
506                 $object_data['item_receiver'] = $receivers;
507                 $object_data['receiver'] = array_replace($object_data['receiver'] ?? [], $receivers);
508                 $object_data['reception_type'] = array_replace($object_data['reception_type'] ?? [], $reception_types);
509
510 //              This check here interferes with Hubzilla posts where the author host differs from the host the post was created
511 //              $author = $object_data['author'] ?? $actor;
512 //              if (!empty($author) && !empty($object_data['id'])) {
513 //                      $author_host = parse_url($author, PHP_URL_HOST);
514 //                      $id_host = parse_url($object_data['id'], PHP_URL_HOST);
515 //                      if ($author_host == $id_host) {
516 //                              Logger::info('Valid hosts', ['type' => $type, 'host' => $id_host]);
517 //                      } else {
518 //                              Logger::notice('Differing hosts on author and id', ['type' => $type, 'author' => $author_host, 'id' => $id_host]);
519 //                              $trust_source = false;
520 //                      }
521 //              }
522
523                 $account = Contact::selectFirstAccount(['platform'], ['nurl' => Strings::normaliseLink($actor)]);
524                 $platform = $account['platform'] ?? '';
525
526                 Logger::info('Processing', ['type' => $object_data['type'], 'object_type' => $object_data['object_type'], 'id' => $object_data['id'], 'actor' => $actor, 'platform' => $platform]);
527
528                 return $object_data;
529         }
530
531         /**
532          * Fetches the first user id from the receiver array
533          *
534          * @param array $receivers Array with receivers
535          * @return integer user id;
536          */
537         public static function getFirstUserFromReceivers(array $receivers): int
538         {
539                 foreach ($receivers as $receiver) {
540                         if (!empty($receiver)) {
541                                 return $receiver;
542                         }
543                 }
544                 return 0;
545         }
546
547         /**
548          * Processes the activity object
549          *
550          * @param array      $activity     Array with activity data
551          * @param string     $body         The unprocessed body
552          * @param int|null   $uid          User ID
553          * @param boolean    $trust_source Do we trust the source?
554          * @param boolean    $push         Message had been pushed to our system
555          * @param array      $signer       The signer of the post
556          *
557          * @return bool
558          *
559          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
560          * @throws \ImagickException
561          */
562         public static function processActivity(array $activity, string $body = '', int $uid = null, bool $trust_source = false, bool $push = false, array $signer = [], string $http_signer = '', int $completion = Receiver::COMPLETION_AUTO): bool
563         {
564                 $type = JsonLD::fetchElement($activity, '@type');
565                 if (!$type) {
566                         Logger::info('Empty type', ['activity' => $activity]);
567                         return true;
568                 }
569
570                 if (!DI::config()->get('system', 'process_view') && ($type == 'as:View')) {
571                         Logger::info('View activities are ignored.', ['signer' => $signer, 'http_signer' => $http_signer]);
572                         return true;
573                 }
574
575                 if (!JsonLD::fetchElement($activity, 'as:object', '@id')) {
576                         Logger::info('Empty object', ['activity' => $activity]);
577                         return true;
578                 }
579
580                 $actor = JsonLD::fetchElement($activity, 'as:actor', '@id');
581                 if (empty($actor)) {
582                         Logger::info('Empty actor', ['activity' => $activity]);
583                         return true;
584                 }
585
586                 if (is_array($activity['as:object'])) {
587                         $attributed_to = JsonLD::fetchElement($activity['as:object'], 'as:attributedTo', '@id');
588                 } else {
589                         $attributed_to = '';
590                 }
591
592                 // Test the provided signatures against the actor and "attributedTo"
593                 if ($trust_source) {
594                         if (!empty($attributed_to) && !empty($actor)) {
595                                 $trust_source = (in_array($actor, $signer) && in_array($attributed_to, $signer));
596                         } else {
597                                 $trust_source = in_array($actor, $signer);
598                         }
599                 }
600
601                 // $trust_source is called by reference and is set to true if the content was retrieved successfully
602                 $object_data = self::prepareObjectData($activity, $uid, $push, $trust_source);
603                 if (empty($object_data)) {
604                         Logger::info('No object data found', ['activity' => $activity]);
605                         return true;
606                 }
607
608                 // Lemmy is announcing activities.
609                 // We are changing the announces into regular activities.
610                 if (($type == 'as:Announce') && in_array($object_data['type'] ?? '', array_merge(self::ACTIVITY_TYPES, ['as:Delete', 'as:Undo', 'as:Update']))) {
611                         Logger::debug('Change type of announce to activity', ['type' => $object_data['type']]);
612                         $type = $object_data['type'];
613                 }
614
615                 if (!empty($body) && empty($object_data['raw'])) {
616                         $object_data['raw'] = $body;
617                 }
618
619                 // Internal flag for thread completion. See Processor.php
620                 if (!empty($activity['thread-completion'])) {
621                         $object_data['thread-completion'] = $activity['thread-completion'];
622                 }
623
624                 if (!empty($activity['completion-mode'])) {
625                         $object_data['completion-mode'] = $activity['completion-mode'];
626                 }
627
628                 if (!empty($activity['thread-children-type'])) {
629                         $object_data['thread-children-type'] = $activity['thread-children-type'];
630                 }
631
632                 // Internal flag for posts that arrived via relay
633                 if (!empty($activity['from-relay'])) {
634                         $object_data['from-relay'] = $activity['from-relay'];
635                 }
636
637                 if ($type == 'as:Announce') {
638                         $object_data['object_activity'] = $activity;
639                 }
640
641                 if (($type == 'as:Create') && $trust_source && !in_array($completion, [self::COMPLETION_MANUAL, self::COMPLETION_ANNOUNCE])) {
642                         if (self::hasArrived($object_data['object_id'])) {
643                                 Logger::info('The activity already arrived.', ['id' => $object_data['object_id']]);
644                                 return true;
645                         }
646                         self::addArrivedId($object_data['object_id']);
647
648                         if (Queue::exists($object_data['object_id'], $type)) {
649                                 Logger::info('The activity is already added.', ['id' => $object_data['object_id']]);
650                                 return true;
651                         }
652                 } elseif (($type == 'as:Create') && $trust_source && !self::hasArrived($object_data['object_id'])) {
653                         self::addArrivedId($object_data['object_id']);
654                 }
655
656                 $decouple = DI::config()->get('system', 'decoupled_receiver') && !in_array($completion, [self::COMPLETION_MANUAL, self::COMPLETION_ANNOUNCE]);
657
658                 if ($decouple && ($trust_source || DI::config()->get('debug', 'ap_inbox_store_untrusted'))) {
659                         $object_data = Queue::add($object_data, $type, $uid, $http_signer, $push, $trust_source);
660                 }
661
662                 if (!$trust_source) {
663                         Logger::info('Activity trust could not be achieved.',  ['id' => $object_data['object_id'], 'type' => $type, 'signer' => $signer, 'actor' => $actor, 'attributedTo' => $attributed_to]);
664                         return true;
665                 }
666
667                 if (!empty($object_data['entry-id']) && $decouple && ($push || ($completion == self::COMPLETION_RELAY))) {
668                         if (Queue::isProcessable($object_data['entry-id'])) {
669                                 // We delay by 5 seconds to allow to accumulate all receivers
670                                 $delayed = date(DateTimeFormat::MYSQL, time() + 5);
671                                 Logger::debug('Initiate processing', ['id' => $object_data['entry-id'], 'uri' => $object_data['object_id']]);
672                                 $wid = Worker::add(['priority' => Worker::PRIORITY_HIGH, 'delayed' => $delayed], 'ProcessQueue', $object_data['entry-id']);
673                                 Queue::setWorkerId($object_data['entry-id'], $wid);
674                         } else {
675                                 Logger::debug('Other queue entries need to be processed first.', ['id' => $object_data['entry-id']]);
676                         }
677                         return false;
678                 }
679
680                 if (!empty($activity['recursion-depth'])) {
681                         $object_data['recursion-depth'] = $activity['recursion-depth'];
682                 }
683
684                 if (!self::routeActivities($object_data, $type, $push, true, $uid)) {
685                         self::storeUnhandledActivity(true, $type, $object_data, $activity, $body, $uid, $trust_source, $push, $signer);
686                         Queue::remove($object_data);
687                 }
688                 return true;
689         }
690
691         /**
692          * Route activities
693          *
694          * @param array  $object_data
695          * @param string $type
696          * @param bool   $push
697          * @param bool   $fetch_parents
698          * @param int    $uid
699          *
700          * @return boolean Could the activity be routed?
701          */
702         public static function routeActivities(array $object_data, string $type, bool $push, bool $fetch_parents = true, int $uid = 0): bool
703         {
704                 switch ($type) {
705                         case 'as:Create':
706                                 if (in_array($object_data['object_type'], self::CONTENT_TYPES)) {
707                                         $item = ActivityPub\Processor::createItem($object_data, $fetch_parents);
708                                         ActivityPub\Processor::postItem($object_data, $item);
709                                 } elseif (in_array($object_data['object_type'], ['pt:CacheFile'])) {
710                                         // Unhandled Peertube activity
711                                         Queue::remove($object_data);
712                                 } elseif (in_array($object_data['object_type'], self::ACCOUNT_TYPES)) {
713                                         ActivityPub\Processor::updatePerson($object_data);
714                                 } else {
715                                         return false;
716                                 }
717                                 break;
718
719                         case 'as:Invite':
720                                 if (in_array($object_data['object_type'], ['as:Event'])) {
721                                         $item = ActivityPub\Processor::createItem($object_data, $fetch_parents);
722                                         ActivityPub\Processor::postItem($object_data, $item);
723                                 } else {
724                                         return false;
725                                 }
726                                 break;
727
728                         case 'as:Add':
729                                 if ($object_data['object_type'] == 'as:tag') {
730                                         ActivityPub\Processor::addTag($object_data);
731                                 } elseif (in_array($object_data['object_type'], self::CONTENT_TYPES)) {
732                                         ActivityPub\Processor::addToFeaturedCollection($object_data);
733                                 } elseif (in_array($object_data['object_type'], ['as:Tombstone', ''])) {
734                                         // We don't have the object here or it is deleted. We ignore this activity.
735                                         Queue::remove($object_data);
736                                 } else {
737                                         return false;
738                                 }
739                                 break;
740
741                         case 'as:Announce':
742                                 if (in_array($object_data['object_type'], self::CONTENT_TYPES)) {
743                                         if (!Item::searchByLink($object_data['object_id'], $uid)) {
744                                                 if (ActivityPub\Processor::fetchMissingActivity($object_data['object_id'], [], $object_data['actor'], self::COMPLETION_ANNOUNCE, $uid)) {
745                                                         Logger::debug('Created announced id', ['uid' => $uid, 'id' => $object_data['object_id']]);
746                                                         Queue::remove($object_data);
747                                                 } else {
748                                                         Logger::debug('Announced id was not created', ['uid' => $uid, 'id' => $object_data['object_id']]);
749                                                         Queue::remove($object_data);
750                                                         return true;
751                                                 }
752                                         } else {
753                                                 Logger::info('Announced id already exists', ['uid' => $uid, 'id' => $object_data['object_id']]);
754                                                 Queue::remove($object_data);
755                                         }
756
757                                         ActivityPub\Processor::createActivity($object_data, Activity::ANNOUNCE);
758                                 } elseif (in_array($object_data['object_type'], ['as:Tombstone', ''])) {
759                                         // We don't have the object here or it is deleted. We ignore this activity.
760                                         Queue::remove($object_data);
761                                 } else {
762                                         return false;
763                                 }
764                                 break;
765
766                         case 'as:Like':
767                                 if (in_array($object_data['object_type'], self::CONTENT_TYPES)) {
768                                         ActivityPub\Processor::createActivity($object_data, Activity::LIKE);
769                                 } elseif (in_array($object_data['object_type'], ['as:Tombstone', ''])) {
770                                         // We don't have the object here or it is deleted. We ignore this activity.
771                                         Queue::remove($object_data);
772                                 } else {
773                                         return false;
774                                 }
775                                 break;
776
777                         case 'as:Dislike':
778                                 if (in_array($object_data['object_type'], self::CONTENT_TYPES)) {
779                                         ActivityPub\Processor::createActivity($object_data, Activity::DISLIKE);
780                                 } elseif (in_array($object_data['object_type'], ['as:Tombstone', ''])) {
781                                         // We don't have the object here or it is deleted. We ignore this activity.
782                                         Queue::remove($object_data);
783                                 } else {
784                                         return false;
785                                 }
786                                 break;
787
788                         case 'as:TentativeAccept':
789                                 if (in_array($object_data['object_type'], self::CONTENT_TYPES)) {
790                                         ActivityPub\Processor::createActivity($object_data, Activity::ATTENDMAYBE);
791                                 } else {
792                                         return false;
793                                 }
794                                 break;
795
796                         case 'as:Update':
797                                 if (in_array($object_data['object_type'], self::CONTENT_TYPES)) {
798                                         ActivityPub\Processor::updateItem($object_data);
799                                 } elseif (in_array($object_data['object_type'], self::ACCOUNT_TYPES)) {
800                                         ActivityPub\Processor::updatePerson($object_data);
801                                 } elseif (in_array($object_data['object_type'], ['pt:CacheFile'])) {
802                                         // Unhandled Peertube activity
803                                         Queue::remove($object_data);
804                                 } else {
805                                         return false;
806                                 }
807                                 break;
808
809                         case 'as:Delete':
810                                 if (in_array($object_data['object_type'], array_merge(['as:Tombstone'], self::CONTENT_TYPES))) {
811                                         ActivityPub\Processor::deleteItem($object_data);
812                                 } elseif (in_array($object_data['object_type'], self::ACCOUNT_TYPES)) {
813                                         ActivityPub\Processor::deletePerson($object_data);
814                                 } elseif ($object_data['object_type'] == '') {
815                                         // The object type couldn't be determined. Most likely we don't have it here. We ignore this activity.
816                                         Queue::remove($object_data);
817                                 } else {
818                                         return false;
819                                 }
820                                 break;
821
822                         case 'as:Move':
823                                 if (in_array($object_data['object_type'], self::ACCOUNT_TYPES)) {
824                                         ActivityPub\Processor::movePerson($object_data);
825                                 } else {
826                                         return false;
827                                 }
828                                 break;
829
830                         case 'as:Block':
831                                 if (in_array($object_data['object_type'], self::ACCOUNT_TYPES)) {
832                                         ActivityPub\Processor::blockAccount($object_data);
833                                 } else {
834                                         return false;
835                                 }
836                                 break;
837
838                         case 'as:Flag':
839                                 if (in_array($object_data['object_type'], self::ACCOUNT_TYPES)) {
840                                         ActivityPub\Processor::ReportAccount($object_data);
841                                 } else {
842                                         return false;
843                                 }
844                                 break;
845
846                         case 'as:Remove':
847                                 if (in_array($object_data['object_type'], self::CONTENT_TYPES)) {
848                                         ActivityPub\Processor::removeFromFeaturedCollection($object_data);
849                                 } elseif (in_array($object_data['object_type'], ['as:Tombstone', ''])) {
850                                         // We don't have the object here or it is deleted. We ignore this activity.
851                                         Queue::remove($object_data);
852                                 } else {
853                                         return false;
854                                 }
855                                 break;
856
857                         case 'as:Follow':
858                                 if (in_array($object_data['object_type'], self::ACCOUNT_TYPES)) {
859                                         ActivityPub\Processor::followUser($object_data);
860                                 } elseif (in_array($object_data['object_type'], self::CONTENT_TYPES)) {
861                                         $object_data['reply-to-id'] = $object_data['object_id'];
862                                         ActivityPub\Processor::createActivity($object_data, Activity::FOLLOW);
863                                 } else {
864                                         return false;
865                                 }
866                                 break;
867
868                         case 'as:Accept':
869                                 if ($object_data['object_type'] == 'as:Follow') {
870                                         if (!empty($object_data['object_actor'])) {
871                                                 ActivityPub\Processor::acceptFollowUser($object_data);
872                                         } else {
873                                                 Logger::notice('Unhandled "accept follow" message.', ['object_data' => $object_data]);
874                                         }
875                                 } elseif (in_array($object_data['object_type'], self::CONTENT_TYPES)) {
876                                         ActivityPub\Processor::createActivity($object_data, Activity::ATTEND);
877                                 } elseif (!empty($object_data['object_id']) && empty($object_data['object_actor']) && empty($object_data['object_type'])) {
878                                         // Follow acceptances from gup.pe only contain the object id
879                                         ActivityPub\Processor::acceptFollowUser($object_data);
880                                 } else {
881                                         return false;
882                                 }
883                                 break;
884
885                         case 'as:Reject':
886                                 if ($object_data['object_type'] == 'as:Follow') {
887                                         ActivityPub\Processor::rejectFollowUser($object_data);
888                                 } elseif (in_array($object_data['object_type'], self::CONTENT_TYPES)) {
889                                         ActivityPub\Processor::createActivity($object_data, Activity::ATTENDNO);
890                                 } else {
891                                         return false;
892                                 }
893                                 break;
894
895                         case 'as:Undo':
896                                 if (($object_data['object_type'] == 'as:Follow') &&
897                                         in_array($object_data['object_object_type'], self::ACCOUNT_TYPES)) {
898                                         ActivityPub\Processor::undoFollowUser($object_data);
899                                 } elseif (($object_data['object_type'] == 'as:Follow') &&
900                                         in_array($object_data['object_object_type'], self::CONTENT_TYPES)) {
901                                         ActivityPub\Processor::undoActivity($object_data);
902                                 } elseif (($object_data['object_type'] == 'as:Accept') &&
903                                         in_array($object_data['object_object_type'], self::ACCOUNT_TYPES)) {
904                                         ActivityPub\Processor::rejectFollowUser($object_data);
905                                 } elseif (($object_data['object_type'] == 'as:Block') &&
906                                         in_array($object_data['object_object_type'], self::ACCOUNT_TYPES)) {
907                                         ActivityPub\Processor::unblockAccount($object_data);
908                                 } elseif (in_array($object_data['object_type'], array_merge(self::ACTIVITY_TYPES, ['as:Announce', 'as:Create', ''])) &&
909                                         empty($object_data['object_object_type'])) {
910                                         // We cannot detect the target object. So we can ignore it.
911                                         Queue::remove($object_data);
912                                 } elseif (in_array($object_data['object_type'], array_merge(self::ACTIVITY_TYPES, ['as:Announce'])) &&
913                                         in_array($object_data['object_object_type'], array_merge(['as:Tombstone'], self::CONTENT_TYPES))) {
914                                         ActivityPub\Processor::undoActivity($object_data);
915                                 } elseif (in_array($object_data['object_type'], ['as:Create']) &&
916                                         in_array($object_data['object_object_type'], ['pt:CacheFile'])) {
917                                         // Unhandled Peertube activity
918                                         Queue::remove($object_data);
919                                 } elseif (in_array($object_data['object_type'], ['as:Delete'])) {
920                                         // We cannot undo deletions, so we just ignore this
921                                         Queue::remove($object_data);
922                                 } elseif (in_array($object_data['object_object_type'], ['as:Tombstone'])) {
923                                         // The object is a tombstone, we ignore any actions on it.
924                                         Queue::remove($object_data);
925                                 } else {
926                                         return false;
927                                 }
928                                 break;
929
930                         case 'as:View':
931                                 if (in_array($object_data['object_type'], self::CONTENT_TYPES)) {
932                                         ActivityPub\Processor::createActivity($object_data, Activity::VIEW);
933                                 } elseif (in_array($object_data['object_type'], ['as:Tombstone', ''])) {
934                                         // We don't have the object here or it is deleted. We ignore this activity.
935                                         Queue::remove($object_data);
936                                 } else {
937                                         return false;
938                                 }
939                                 break;
940                         case 'as:Read':
941                                 if (in_array($object_data['object_type'], self::CONTENT_TYPES)) {
942                                         ActivityPub\Processor::createActivity($object_data, Activity::READ);
943                                 } elseif (in_array($object_data['object_type'], ['as:Tombstone', ''])) {
944                                         // We don't have the object here or it is deleted. We ignore this activity.
945                                         Queue::remove($object_data);
946                                 } else {
947                                         return false;
948                                 }
949                                 break;
950
951                         case 'litepub:EmojiReact':
952                                 if (in_array($object_data['object_type'], self::CONTENT_TYPES)) {
953                                         ActivityPub\Processor::createActivity($object_data, Activity::EMOJIREACT);
954                                 } elseif (in_array($object_data['object_type'], ['as:Tombstone', ''])) {
955                                         // We don't have the object here or it is deleted. We ignore this activity.
956                                         Queue::remove($object_data);
957                                 } else {
958                                         return false;
959                                 }
960                                 break;
961
962                         default:
963                                 Logger::info('Unknown activity: ' . $type . ' ' . $object_data['object_type']);
964                                 return false;
965                 }
966                 return true;
967         }
968
969         /**
970          * Stores unhandled or unknown Activities as a file
971          *
972          * @param boolean $unknown      "true" if the activity is unknown, "false" if it is unhandled
973          * @param string  $type         Activity type
974          * @param array   $object_data  Preprocessed array that is generated out of the received activity
975          * @param array   $activity     Array with activity data
976          * @param string  $body         The unprocessed body
977          * @param integer $uid          User ID
978          * @param boolean $trust_source Do we trust the source?
979          * @param boolean $push         Message had been pushed to our system
980          * @param array   $signer       The signer of the post
981          * @return void
982          */
983         private static function storeUnhandledActivity(bool $unknown, string $type, array $object_data, array $activity, string $body = '', int $uid = null, bool $trust_source = false, bool $push = false, array $signer = [])
984         {
985                 if (!DI::config()->get('debug', 'ap_log_unknown')) {
986                         return;
987                 }
988
989                 $file = ($unknown  ? 'unknown-' : 'unhandled-') . str_replace(':', '-', $type) . '-';
990
991                 if (!empty($object_data['object_type'])) {
992                         $file .= str_replace(':', '-', $object_data['object_type']) . '-';
993                 }
994
995                 if (!empty($object_data['object_object_type'])) {
996                         $file .= str_replace(':', '-', $object_data['object_object_type']) . '-';
997                 }
998
999                 $tempfile = tempnam(System::getTempPath(), $file);
1000                 file_put_contents($tempfile, json_encode(['activity' => $activity, 'body' => $body, 'uid' => $uid, 'trust_source' => $trust_source, 'push' => $push, 'signer' => $signer, 'object_data' => $object_data], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
1001                 Logger::notice('Unknown activity stored', ['type' => $type, 'object_type' => $object_data['object_type'], 'object_object_type' => $object_data['object_object_type'] ?? '', 'file' => $tempfile]);
1002         }
1003
1004         /**
1005          * Fetch a user id from an activity array
1006          *
1007          * @param array  $activity
1008          * @param string $actor
1009          *
1010          * @return int   user id
1011          */
1012         public static function getBestUserForActivity(array $activity): int
1013         {
1014                 $uid = 0;
1015                 $actor = JsonLD::fetchElement($activity, 'as:actor', '@id') ?? '';
1016
1017                 $receivers = self::getReceivers($activity, $actor, [], false, false);
1018                 foreach ($receivers as $receiver) {
1019                         if ($receiver['type'] == self::TARGET_GLOBAL) {
1020                                 return 0;
1021                         }
1022                         if (empty($uid) || ($receiver['type'] == self::TARGET_TO)) {
1023                                 $uid = $receiver['uid'];
1024                         }
1025                 }
1026
1027                 // When we haven't found any user yet, we just chose a user who most likely could have access to the content
1028                 if (empty($uid)) {
1029                         $contact = Contact::selectFirst(['uid'], ['nurl' => Strings::normaliseLink($actor), 'rel' => [Contact::SHARING, Contact::FRIEND]]);
1030                         if (!empty($contact['uid'])) {
1031                                 $uid = $contact['uid'];
1032                         }
1033                 }
1034
1035                 return $uid;
1036         }
1037
1038         // @TODO Missing documentation
1039         public static function getReceiverURL(array $activity): array
1040         {
1041                 $urls = [];
1042
1043                 foreach (['as:to', 'as:cc', 'as:bto', 'as:bcc', 'as:audience', 'as:attributedTo'] as $element) {
1044                         $receiver_list = JsonLD::fetchElementArray($activity, $element, '@id');
1045                         if (empty($receiver_list)) {
1046                                 continue;
1047                         }
1048
1049                         foreach ($receiver_list as $receiver) {
1050                                 if ($receiver == 'Public') {
1051                                         Logger::warning('Not compacted public collection found', ['activity' => $activity, 'callstack' => System::callstack(20)]);
1052                                         $receiver = ActivityPub::PUBLIC_COLLECTION;
1053                                 }
1054                                 if ($receiver == self::PUBLIC_COLLECTION) {
1055                                         $receiver = ActivityPub::PUBLIC_COLLECTION;
1056                                 }
1057                                 $urls[$element][] = $receiver;
1058                         }
1059                 }
1060
1061                 return $urls;
1062         }
1063
1064         /**
1065          * Fetch the receiver list from an activity array
1066          *
1067          * @param array   $activity
1068          * @param string $actor
1069          * @param array  $tags
1070          * @param bool   $fetch_unlisted
1071          * @param bool   $push
1072          *
1073          * @return array with receivers (user id)
1074          * @throws \Exception
1075          */
1076         private static function getReceivers(array $activity, string $actor, array $tags, bool $fetch_unlisted, bool $push): array
1077         {
1078                 $reply = $receivers = $profile = [];
1079
1080                 // When it is an answer, we inherit the receivers from the parent
1081                 $replyto = JsonLD::fetchElement($activity, 'as:inReplyTo', '@id');
1082                 if (!empty($replyto)) {
1083                         $reply = [$replyto];
1084
1085                         // Fix possibly wrong item URI (could be an answer to a plink uri)
1086                         $fixedReplyTo = Item::getURIByLink($replyto);
1087                         if (!empty($fixedReplyTo)) {
1088                                 $reply[] = $fixedReplyTo;
1089                         }
1090                 }
1091
1092                 // Fetch all posts that refer to the object id
1093                 $object_id = JsonLD::fetchElement($activity, 'as:object', '@id');
1094                 if (!empty($object_id)) {
1095                         $reply[] = $object_id;
1096                 }
1097
1098                 if (!empty($actor)) {
1099                         $profile   = APContact::getByURL($actor);
1100                         $followers = $profile['followers'] ?? '';
1101                         $is_forum  = ($actor['type'] ?? '') == 'Group';
1102                         if ($push) {
1103                                 Contact::updateByUrlIfNeeded($actor);
1104                         }
1105                         Logger::info('Got actor and followers', ['actor' => $actor, 'followers' => $followers]);
1106                 } else {
1107                         Logger::info('Empty actor', ['activity' => $activity]);
1108                         $followers = '';
1109                         $is_forum  = false;
1110                 }
1111
1112                 // We have to prevent false follower assumptions upon thread completions
1113                 $follower_target = empty($activity['thread-completion']) ? self::TARGET_FOLLOWER : self::TARGET_UNKNOWN;
1114
1115                 foreach (['as:to', 'as:cc', 'as:bto', 'as:bcc','as:audience'] as $element) {
1116                         $receiver_list = JsonLD::fetchElementArray($activity, $element, '@id');
1117                         if (empty($receiver_list)) {
1118                                 continue;
1119                         }
1120
1121                         foreach ($receiver_list as $receiver) {
1122                                 if ($receiver == self::PUBLIC_COLLECTION) {
1123                                         $receivers[0] = ['uid' => 0, 'type' => self::TARGET_GLOBAL];
1124                                 }
1125
1126                                 // Add receiver "-1" for unlisted posts
1127                                 if ($fetch_unlisted && ($receiver == self::PUBLIC_COLLECTION) && ($element == 'as:cc')) {
1128                                         $receivers[-1] = ['uid' => -1, 'type' => self::TARGET_GLOBAL];
1129                                 }
1130
1131                                 // Fetch the receivers for the public and the followers collection
1132                                 if ((($receiver == $followers) || (($receiver == self::PUBLIC_COLLECTION) && !$is_forum)) && !empty($actor)) {
1133                                         $receivers = self::getReceiverForActor($actor, $tags, $receivers, $follower_target, $profile);
1134                                         continue;
1135                                 }
1136
1137                                 // Fetching all directly addressed receivers
1138                                 $condition = ['self' => true, 'nurl' => Strings::normaliseLink($receiver)];
1139                                 $contact = DBA::selectFirst('contact', ['uid', 'contact-type'], $condition);
1140                                 if (!DBA::isResult($contact)) {
1141                                         continue;
1142                                 }
1143
1144                                 // Check if the potential receiver is following the actor
1145                                 // Exception: The receiver is targetted via "to" or this is a comment
1146                                 if ((($element != 'as:to') && empty($replyto)) || ($contact['contact-type'] == Contact::TYPE_COMMUNITY)) {
1147                                         $networks = Protocol::FEDERATED;
1148                                         $condition = ['nurl' => Strings::normaliseLink($actor), 'rel' => [Contact::SHARING, Contact::FRIEND],
1149                                                 'network' => $networks, 'archive' => false, 'pending' => false, 'uid' => $contact['uid']];
1150
1151                                         // Forum posts are only accepted from forum contacts
1152                                         if ($contact['contact-type'] == Contact::TYPE_COMMUNITY) {
1153                                                 $condition['rel'] = [Contact::SHARING, Contact::FRIEND, Contact::FOLLOWER];
1154                                         }
1155
1156                                         if (!DBA::exists('contact', $condition)) {
1157                                                 continue;
1158                                         }
1159                                 }
1160
1161                                 $type = $receivers[$contact['uid']]['type'] ?? self::TARGET_UNKNOWN;
1162                                 if (in_array($type, [self::TARGET_UNKNOWN, self::TARGET_FOLLOWER, self::TARGET_ANSWER, self::TARGET_GLOBAL])) {
1163                                         switch ($element) {
1164                                                 case 'as:to':
1165                                                         $type = self::TARGET_TO;
1166                                                         break;
1167                                                 case 'as:cc':
1168                                                         $type = self::TARGET_CC;
1169                                                         break;
1170                                                 case 'as:bto':
1171                                                         $type = self::TARGET_BTO;
1172                                                         break;
1173                                                 case 'as:bcc':
1174                                                         $type = self::TARGET_BCC;
1175                                                         break;
1176                                                 case 'as:audience':
1177                                                         $type = self::TARGET_AUDIENCE;
1178                                                         break;
1179                                         }
1180
1181                                         $receivers[$contact['uid']] = ['uid' => $contact['uid'], 'type' => $type];
1182                                 }
1183                         }
1184                 }
1185
1186                 if (!empty($reply) && (!empty($receivers[0]) || !empty($receivers[-1]))) {
1187                         $parents = Post::select(['uid'], DBA::mergeConditions(['uri' => $reply], ["`uid` != ?", 0]));
1188                         while ($parent = Post::fetch($parents)) {
1189                                 $receivers[$parent['uid']] = ['uid' => $parent['uid'], 'type' => self::TARGET_ANSWER];
1190                         }
1191                         DBA::close($parents);
1192                 }
1193
1194                 self::switchContacts($receivers, $actor);
1195
1196                 // "birdsitelive" is a service that mirrors tweets into the fediverse
1197                 // These posts can be fetched without authentication, but are not marked as public
1198                 // We treat them as unlisted posts to be able to handle them.
1199                 if (empty($receivers) && $fetch_unlisted && Contact::isPlatform($actor, 'birdsitelive')) {
1200                         $receivers[0]  = ['uid' => 0, 'type' => self::TARGET_GLOBAL];
1201                         $receivers[-1] = ['uid' => -1, 'type' => self::TARGET_GLOBAL];
1202                         Logger::notice('Post from "birdsitelive" is set to "unlisted"', ['id' => JsonLD::fetchElement($activity, '@id')]);
1203                 } elseif (empty($receivers)) {
1204                         Logger::notice('Post has got no receivers', ['fetch_unlisted' => $fetch_unlisted, 'actor' => $actor, 'id' => JsonLD::fetchElement($activity, '@id'), 'type' => JsonLD::fetchElement($activity, '@type')]);
1205                 }
1206
1207                 return $receivers;
1208         }
1209
1210         /**
1211          * Fetch the receiver list of a given actor
1212          *
1213          * @param string  $actor
1214          * @param array   $tags
1215          * @param array   $receivers
1216          * @param integer $target_type
1217          * @param array   $profile
1218          *
1219          * @return array with receivers (user id)
1220          * @throws \Exception
1221          */
1222         private static function getReceiverForActor(string $actor, array $tags, array $receivers, int $target_type, array $profile): array
1223         {
1224                 $basecondition = ['rel' => [Contact::SHARING, Contact::FRIEND, Contact::FOLLOWER],
1225                         'network' => Protocol::FEDERATED, 'archive' => false, 'pending' => false];
1226
1227                 if (!empty($profile['uri-id'])) {
1228                         $condition = DBA::mergeConditions($basecondition, ["`uri-id` = ? AND `uid` != ?", $profile['uri-id'], 0]);
1229                         $contacts = DBA::select('contact', ['uid', 'rel'], $condition);
1230                         while ($contact = DBA::fetch($contacts)) {
1231                                 if (empty($receivers[$contact['uid']]) && self::isValidReceiverForActor($contact, $tags)) {
1232                                         $receivers[$contact['uid']] = ['uid' => $contact['uid'], 'type' => $target_type];
1233                                 }
1234                         }
1235                         DBA::close($contacts);
1236                 } else {
1237                         // This part will only be called while post update 1426 wasn't finished
1238                         $condition = DBA::mergeConditions($basecondition, ["`nurl` = ? AND `uid` != ?", Strings::normaliseLink($actor), 0]);
1239                         $contacts = DBA::select('contact', ['uid', 'rel'], $condition);
1240                         while ($contact = DBA::fetch($contacts)) {
1241                                 if (empty($receivers[$contact['uid']]) && self::isValidReceiverForActor($contact, $tags)) {
1242                                         $receivers[$contact['uid']] = ['uid' => $contact['uid'], 'type' => $target_type];
1243                                 }
1244                         }
1245                         DBA::close($contacts);
1246
1247                         // The queries are split because of performance issues
1248                         $condition = DBA::mergeConditions($basecondition, ["`alias` IN (?, ?) AND `uid` != ?", Strings::normaliseLink($actor), $actor, 0]);
1249                         $contacts = DBA::select('contact', ['uid', 'rel'], $condition);
1250                         while ($contact = DBA::fetch($contacts)) {
1251                                 if (empty($receivers[$contact['uid']]) && self::isValidReceiverForActor($contact, $tags)) {
1252                                         $receivers[$contact['uid']] = ['uid' => $contact['uid'], 'type' => $target_type];
1253                                 }
1254                         }
1255                         DBA::close($contacts);
1256                 }
1257                 return $receivers;
1258         }
1259
1260         /**
1261          * Tests if the contact is a valid receiver for this actor
1262          *
1263          * @param array  $contact
1264          * @param array  $tags
1265          *
1266          * @return bool with receivers (user id)
1267          * @throws \Exception
1268          */
1269         private static function isValidReceiverForActor(array $contact, array $tags): bool
1270         {
1271                 // Are we following the contact? Then this is a valid receiver
1272                 if (in_array($contact['rel'], [Contact::SHARING, Contact::FRIEND])) {
1273                         return true;
1274                 }
1275
1276                 // When the possible receiver isn't a community, then it is no valid receiver
1277                 $owner = User::getOwnerDataById($contact['uid']);
1278                 if (empty($owner) || ($owner['contact-type'] != Contact::TYPE_COMMUNITY)) {
1279                         return false;
1280                 }
1281
1282                 // Is the community account tagged?
1283                 foreach ($tags as $tag) {
1284                         if ($tag['type'] != 'Mention') {
1285                                 continue;
1286                         }
1287
1288                         if (Strings::compareLink($tag['href'], $owner['url'])) {
1289                                 return true;
1290                         }
1291                 }
1292
1293                 return false;
1294         }
1295
1296         /**
1297          * Switches existing contacts to ActivityPub
1298          *
1299          * @param integer $cid Contact ID
1300          * @param integer $uid User ID
1301          * @param string  $url Profile URL
1302          * @return void
1303          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1304          * @throws \ImagickException
1305          */
1306         public static function switchContact(int $cid, int $uid, string $url)
1307         {
1308                 if (DBA::exists('contact', ['id' => $cid, 'network' => Protocol::ACTIVITYPUB])) {
1309                         Logger::info('Contact is already ActivityPub', ['id' => $cid, 'uid' => $uid, 'url' => $url]);
1310                         return;
1311                 }
1312
1313                 if (Contact::updateFromProbe($cid)) {
1314                         Logger::info('Update was successful', ['id' => $cid, 'uid' => $uid, 'url' => $url]);
1315                 }
1316
1317                 // Send a new follow request to be sure that the connection still exists
1318                 if (($uid != 0) && DBA::exists('contact', ['id' => $cid, 'rel' => [Contact::SHARING, Contact::FRIEND], 'network' => Protocol::ACTIVITYPUB])) {
1319                         Logger::info('Contact had been switched to ActivityPub. Sending a new follow request.', ['uid' => $uid, 'url' => $url]);
1320                         ActivityPub\Transmitter::sendActivity('Follow', $url, $uid);
1321                 }
1322         }
1323
1324         /**
1325          * @TODO Fix documentation and type-hints
1326          *
1327          * @param $receivers
1328          * @param $actor
1329          * @return void
1330          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1331          * @throws \ImagickException
1332          */
1333         private static function switchContacts($receivers, $actor)
1334         {
1335                 if (empty($actor)) {
1336                         return;
1337                 }
1338
1339                 foreach ($receivers as $receiver) {
1340                         $contact = DBA::selectFirst('contact', ['id'], ['uid' => $receiver['uid'], 'network' => Protocol::OSTATUS, 'nurl' => Strings::normaliseLink($actor)]);
1341                         if (DBA::isResult($contact)) {
1342                                 self::switchContact($contact['id'], $receiver['uid'], $actor);
1343                         }
1344
1345                         $contact = DBA::selectFirst('contact', ['id'], ['uid' => $receiver['uid'], 'network' => Protocol::OSTATUS, 'alias' => [Strings::normaliseLink($actor), $actor]]);
1346                         if (DBA::isResult($contact)) {
1347                                 self::switchContact($contact['id'], $receiver['uid'], $actor);
1348                         }
1349                 }
1350         }
1351
1352         /**
1353          * @TODO Fix documentation and type-hints
1354          *
1355          * @param       $object_data
1356          * @param array $activity
1357          *
1358          * @return mixed
1359          */
1360         private static function addActivityFields($object_data, array $activity)
1361         {
1362                 if (!empty($activity['published']) && empty($object_data['published'])) {
1363                         $object_data['published'] = JsonLD::fetchElement($activity, 'as:published', '@value');
1364                 }
1365
1366                 if (!empty($activity['diaspora:guid']) && empty($object_data['diaspora:guid'])) {
1367                         $object_data['diaspora:guid'] = JsonLD::fetchElement($activity, 'diaspora:guid', '@value');
1368                 }
1369
1370                 $object_data['service'] = JsonLD::fetchElement($activity, 'as:instrument', 'as:name', '@type', 'as:Service');
1371                 $object_data['service'] = JsonLD::fetchElement($object_data, 'service', '@value');
1372
1373                 if (!empty($object_data['object_id'])) {
1374                         // Some systems (e.g. GNU Social) don't reply to the "id" field but the "uri" field.
1375                         $objectId = Item::getURIByLink($object_data['object_id']);
1376                         if (!empty($objectId) && ($object_data['object_id'] != $objectId)) {
1377                                 Logger::notice('Fix wrong object-id', ['received' => $object_data['object_id'], 'correct' => $objectId]);
1378                                 $object_data['object_id'] = $objectId;
1379                         }
1380                 }
1381
1382                 return $object_data;
1383         }
1384
1385         /**
1386          * Fetches the object data from external resources if needed
1387          *
1388          * @param string  $object_id    Object ID of the provided object
1389          * @param array   $object       The provided object array
1390          * @param boolean $trust_source Do we trust the provided object?
1391          * @param integer $uid          User ID for the signature that we use to fetch data
1392          *
1393          * @return array|false with trusted and valid object data
1394          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1395          * @throws \ImagickException
1396          */
1397         private static function fetchObject(string $object_id, array $object = [], bool $trust_source = false, int $uid = 0)
1398         {
1399                 // By fetching the type we check if the object is complete.
1400                 $type = JsonLD::fetchElement($object, '@type');
1401
1402                 if (!$trust_source || empty($type)) {
1403                         $data = Processor::fetchCachedActivity($object_id, $uid);
1404                         if (!empty($data)) {
1405                                 $object = JsonLD::compact($data);
1406                                 Logger::info('Fetched content for ' . $object_id);
1407                         } else {
1408                                 Logger::info('Empty content for ' . $object_id . ', check if content is available locally.');
1409
1410                                 $item = Post::selectFirst(Item::DELIVER_FIELDLIST, ['uri' => $object_id]);
1411                                 if (!DBA::isResult($item)) {
1412                                         Logger::info('Object with url ' . $object_id . ' was not found locally.');
1413                                         return false;
1414                                 }
1415                                 Logger::info('Using already stored item for url ' . $object_id);
1416                                 $data = ActivityPub\Transmitter::createNote($item);
1417                                 $object = JsonLD::compact($data);
1418                         }
1419
1420                         $id = JsonLD::fetchElement($object, '@id');
1421                         if (empty($id)) {
1422                                 Logger::info('Empty id');
1423                                 return false;
1424                         }
1425
1426                         if ($id != $object_id) {
1427                                 Logger::info('Fetched id differs from provided id', ['provided' => $object_id, 'fetched' => $id]);
1428                                 return false;
1429                         }
1430                 } else {
1431                         Logger::info('Using original object for url ' . $object_id);
1432                 }
1433
1434                 $type = JsonLD::fetchElement($object, '@type');
1435                 if (empty($type)) {
1436                         Logger::info('Empty type');
1437                         return false;
1438                 }
1439
1440                 // Lemmy is resharing "create" activities instead of content
1441                 // We fetch the content from the activity.
1442                 if (in_array($type, ['as:Create'])) {
1443                         $object = $object['as:object'];
1444                         $type = JsonLD::fetchElement($object, '@type');
1445                         if (empty($type)) {
1446                                 Logger::info('Empty type');
1447                                 return false;
1448                         }
1449                         $object_data = self::processObject($object);
1450                 }
1451
1452                 // We currently don't handle 'pt:CacheFile', but with this step we avoid logging
1453                 if (in_array($type, self::CONTENT_TYPES) || ($type == 'pt:CacheFile')) {
1454                         $object_data = self::processObject($object);
1455
1456                         if (!empty($data)) {
1457                                 $object_data['raw-object'] = json_encode($data);
1458                         }
1459                         return $object_data;
1460                 }
1461
1462                 Logger::info('Unhandled object type: ' . $type);
1463                 return false;
1464         }
1465
1466         /**
1467          * Converts the language element (Used by Peertube)
1468          *
1469          * @param array $languages
1470          * @return array Languages
1471          */
1472         public static function processLanguages(array $languages): array
1473         {
1474                 if (empty($languages)) {
1475                         return [];
1476                 }
1477
1478                 $language_list = [];
1479
1480                 foreach ($languages as $language) {
1481                         if (!empty($language['_:identifier']) && !empty($language['as:name'])) {
1482                                 $language_list[$language['_:identifier']] = $language['as:name'];
1483                         }
1484                 }
1485                 return $language_list;
1486         }
1487
1488         /**
1489          * Convert tags from JSON-LD format into a simplified format
1490          *
1491          * @param array $tags Tags in JSON-LD format
1492          *
1493          * @return array with tags in a simplified format
1494          */
1495         public static function processTags(array $tags): array
1496         {
1497                 $taglist = [];
1498
1499                 foreach ($tags as $tag) {
1500                         if (empty($tag)) {
1501                                 continue;
1502                         }
1503
1504                         $element = ['type' => str_replace('as:', '', JsonLD::fetchElement($tag, '@type') ?? ''),
1505                                 'href' => JsonLD::fetchElement($tag, 'as:href', '@id'),
1506                                 'name' => JsonLD::fetchElement($tag, 'as:name', '@value')];
1507
1508                         if (empty($element['type'])) {
1509                                 continue;
1510                         }
1511
1512                         if (empty($element['href'])) {
1513                                 $element['href'] = $element['name'];
1514                         }
1515
1516                         $taglist[] = $element;
1517                 }
1518                 return $taglist;
1519         }
1520
1521         /**
1522          * Convert emojis from JSON-LD format into a simplified format
1523          *
1524          * @param array $emojis
1525          * @return array with emojis in a simplified format
1526          */
1527         private static function processEmojis(array $emojis): array
1528         {
1529                 $emojilist = [];
1530
1531                 foreach ($emojis as $emoji) {
1532                         if (empty($emoji) || (JsonLD::fetchElement($emoji, '@type') != 'toot:Emoji') || empty($emoji['as:icon'])) {
1533                                 continue;
1534                         }
1535
1536                         $url = JsonLD::fetchElement($emoji['as:icon'], 'as:url', '@id');
1537                         $element = ['name' => JsonLD::fetchElement($emoji, 'as:name', '@value'),
1538                                 'href' => $url];
1539
1540                         $emojilist[] = $element;
1541                 }
1542
1543                 return $emojilist;
1544         }
1545
1546         /**
1547          * Convert attachments from JSON-LD format into a simplified format
1548          *
1549          * @param array $attachments Attachments in JSON-LD format
1550          *
1551          * @return array Attachments in a simplified format
1552          */
1553         private static function processAttachments(array $attachments): array
1554         {
1555                 $attachlist = [];
1556
1557                 // Removes empty values
1558                 $attachments = array_filter($attachments);
1559
1560                 foreach ($attachments as $attachment) {
1561                         switch (JsonLD::fetchElement($attachment, '@type')) {
1562                                 case 'as:Page':
1563                                         $pageUrl = null;
1564                                         $pageImage = null;
1565
1566                                         $urls = JsonLD::fetchElementArray($attachment, 'as:url');
1567                                         foreach ($urls as $url) {
1568                                                 // Single scalar URL case
1569                                                 if (is_string($url)) {
1570                                                         $pageUrl = $url;
1571                                                         continue;
1572                                                 }
1573
1574                                                 $href = JsonLD::fetchElement($url, 'as:href', '@id');
1575                                                 $mediaType = JsonLD::fetchElement($url, 'as:mediaType', '@value');
1576                                                 if (Strings::startsWith($mediaType, 'image')) {
1577                                                         $pageImage = $href;
1578                                                 } else {
1579                                                         $pageUrl = $href;
1580                                                 }
1581                                         }
1582
1583                                         $attachlist[] = [
1584                                                 'type'  => 'link',
1585                                                 'title' => JsonLD::fetchElement($attachment, 'as:name', '@value'),
1586                                                 'desc'  => JsonLD::fetchElement($attachment, 'as:summary', '@value'),
1587                                                 'url'   => $pageUrl,
1588                                                 'image' => $pageImage,
1589                                         ];
1590                                         break;
1591                                 case 'as:Image':
1592                                         $mediaType = JsonLD::fetchElement($attachment, 'as:mediaType', '@value');
1593                                         $imageFullUrl = JsonLD::fetchElement($attachment, 'as:url', '@id');
1594                                         $imagePreviewUrl = null;
1595                                         // Multiple URLs?
1596                                         if (!$imageFullUrl && ($urls = JsonLD::fetchElementArray($attachment, 'as:url'))) {
1597                                                 $imageVariants = [];
1598                                                 $previewVariants = [];
1599                                                 foreach ($urls as $url) {
1600                                                         // Scalar URL, no discrimination possible
1601                                                         if (is_string($url)) {
1602                                                                 $imageFullUrl = $url;
1603                                                                 continue;
1604                                                         }
1605
1606                                                         // Not sure what to do with a different Link media type than the base Image, we skip
1607                                                         if ($mediaType != JsonLD::fetchElement($url, 'as:mediaType', '@value')) {
1608                                                                 continue;
1609                                                         }
1610
1611                                                         $href = JsonLD::fetchElement($url, 'as:href', '@id');
1612
1613                                                         // Default URL choice if no discriminating width is provided
1614                                                         $imageFullUrl = $href ?? $imageFullUrl;
1615
1616                                                         $width = intval(JsonLD::fetchElement($url, 'as:width', '@value') ?? 1);
1617
1618                                                         if ($href && $width) {
1619                                                                 $imageVariants[$width] = $href;
1620                                                                 // 632 is the ideal width for full screen frio posts, we compute the absolute distance to it
1621                                                                 $previewVariants[abs(632 - $width)] = $href;
1622                                                         }
1623                                                 }
1624
1625                                                 if ($imageVariants) {
1626                                                         // Taking the maximum size image
1627                                                         ksort($imageVariants);
1628                                                         $imageFullUrl = array_pop($imageVariants);
1629
1630                                                         // Taking the minimum number distance to the target distance
1631                                                         ksort($previewVariants);
1632                                                         $imagePreviewUrl = array_shift($previewVariants);
1633                                                 }
1634
1635                                                 unset($imageVariants);
1636                                                 unset($previewVariants);
1637                                         }
1638
1639                                         $attachlist[] = [
1640                                                 'type' => str_replace('as:', '', JsonLD::fetchElement($attachment, '@type')),
1641                                                 'mediaType' => $mediaType,
1642                                                 'name'  => JsonLD::fetchElement($attachment, 'as:name', '@value'),
1643                                                 'url'   => $imageFullUrl,
1644                                                 'image' => $imagePreviewUrl !== $imageFullUrl ? $imagePreviewUrl : null,
1645                                         ];
1646                                         break;
1647                                 default:
1648                                         $attachlist[] = [
1649                                                 'type' => str_replace('as:', '', JsonLD::fetchElement($attachment, '@type')),
1650                                                 'mediaType' => JsonLD::fetchElement($attachment, 'as:mediaType', '@value'),
1651                                                 'name' => JsonLD::fetchElement($attachment, 'as:name', '@value'),
1652                                                 'url' => JsonLD::fetchElement($attachment, 'as:url', '@id') ?? JsonLD::fetchElement($attachment, 'as:href', '@id'),
1653                                                 'height' => JsonLD::fetchElement($attachment, 'as:height', '@value'),
1654                                                 'width' => JsonLD::fetchElement($attachment, 'as:width', '@value'),
1655                                                 'image' => JsonLD::fetchElement($attachment, 'as:image', '@id')
1656                                         ];
1657                         }
1658                 }
1659
1660                 return $attachlist;
1661         }
1662
1663         /**
1664          * Convert questions from JSON-LD format into a simplified format
1665          *
1666          * @param array $object
1667          *
1668          * @return array Questions in a simplified format
1669          */
1670         private static function processQuestion(array $object): array
1671         {
1672                 $question = [];
1673
1674                 if (!empty($object['as:oneOf'])) {
1675                         $question['multiple'] = false;
1676                         $options = JsonLD::fetchElementArray($object, 'as:oneOf') ?? [];
1677                 } elseif (!empty($object['as:anyOf'])) {
1678                         $question['multiple'] = true;
1679                         $options = JsonLD::fetchElementArray($object, 'as:anyOf') ?? [];
1680                 } else {
1681                         return [];
1682                 }
1683
1684                 $closed = JsonLD::fetchElement($object, 'as:closed', '@value');
1685                 if (!empty($closed)) {
1686                         $question['end-time'] = $closed;
1687                 } else {
1688                         $question['end-time'] = JsonLD::fetchElement($object, 'as:endTime', '@value');
1689                 }
1690
1691                 $question['voters']  = (int)JsonLD::fetchElement($object, 'toot:votersCount', '@value');
1692                 $question['options'] = [];
1693
1694                 $voters = 0;
1695
1696                 foreach ($options as $option) {
1697                         if (JsonLD::fetchElement($option, '@type') != 'as:Note') {
1698                                 continue;
1699                         }
1700
1701                         $name = JsonLD::fetchElement($option, 'as:name', '@value');
1702
1703                         if (empty($option['as:replies'])) {
1704                                 continue;
1705                         }
1706
1707                         $replies = JsonLD::fetchElement($option['as:replies'], 'as:totalItems', '@value');
1708
1709                         $question['options'][] = ['name' => $name, 'replies' => $replies];
1710
1711                         $voters += (int)$replies;
1712                 }
1713
1714                 // For single choice question we can count the number of voters if not provided (like with Misskey)
1715                 if (empty($question['voters']) && !$question['multiple']) {
1716                         $question['voters'] = $voters;
1717                 }
1718
1719                 return $question;
1720         }
1721
1722         /**
1723          * Fetch the original source or content with the "language" Markdown or HTML
1724          *
1725          * @param array $object
1726          * @param array $object_data
1727          *
1728          * @return array Object data (?)
1729          * @throws \Exception
1730          */
1731         private static function getSource(array $object, array $object_data): array
1732         {
1733                 $object_data['source'] = JsonLD::fetchElement($object, 'as:source', 'as:content', 'as:mediaType', 'text/bbcode');
1734                 $object_data['source'] = JsonLD::fetchElement($object_data, 'source', '@value');
1735                 if (!empty($object_data['source'])) {
1736                         return $object_data;
1737                 }
1738
1739                 $object_data['source'] = JsonLD::fetchElement($object, 'as:source', 'as:content', 'as:mediaType', 'text/markdown');
1740                 $object_data['source'] = JsonLD::fetchElement($object_data, 'source', '@value');
1741                 if (!empty($object_data['source'])) {
1742                         $object_data['source'] = Markdown::toBBCode($object_data['source']);
1743                         return $object_data;
1744                 }
1745
1746                 $object_data['source'] = JsonLD::fetchElement($object, 'as:source', 'as:content', 'as:mediaType', 'text/html');
1747                 $object_data['source'] = JsonLD::fetchElement($object_data, 'source', '@value');
1748                 if (!empty($object_data['source'])) {
1749                         $object_data['source'] = HTML::toBBCode($object_data['source']);
1750                         return $object_data;
1751                 }
1752
1753                 return $object_data;
1754         }
1755
1756         /**
1757          * Extracts a potential alternate URL from a list of additional URL elements
1758          *
1759          * @param array $urls
1760          * @return string
1761          */
1762         private static function extractAlternateUrl(array $urls): string
1763         {
1764                 $alternateUrl = '';
1765                 foreach ($urls as $key => $url) {
1766                         // Not a list but a single URL element
1767                         if (!is_numeric($key)) {
1768                                 continue;
1769                         }
1770
1771                         if (empty($url['@type']) || ($url['@type'] != 'as:Link')) {
1772                                 continue;
1773                         }
1774
1775                         $href = JsonLD::fetchElement($url, 'as:href', '@id');
1776                         if (empty($href)) {
1777                                 continue;
1778                         }
1779
1780                         $mediatype = JsonLD::fetchElement($url, 'as:mediaType');
1781                         if (empty($mediatype)) {
1782                                 continue;
1783                         }
1784
1785                         if ($mediatype == 'text/html') {
1786                                 $alternateUrl = $href;
1787                         }
1788                 }
1789
1790                 return $alternateUrl;
1791         }
1792
1793         /**
1794          * Check if the "as:url" element is an array with multiple links
1795          * This is the case with audio and video posts.
1796          * Then the links are added as attachments
1797          *
1798          * @param array $urls The object URL list
1799          * @return array an array of attachments
1800          */
1801         private static function processAttachmentUrls(array $urls): array
1802         {
1803                 $attachments = [];
1804                 foreach ($urls as $key => $url) {
1805                         // Not a list but a single URL element
1806                         if (!is_numeric($key)) {
1807                                 continue;
1808                         }
1809
1810                         if (empty($url['@type']) || ($url['@type'] != 'as:Link')) {
1811                                 continue;
1812                         }
1813
1814                         $href = JsonLD::fetchElement($url, 'as:href', '@id');
1815                         if (empty($href)) {
1816                                 continue;
1817                         }
1818
1819                         $mediatype = JsonLD::fetchElement($url, 'as:mediaType');
1820                         if (empty($mediatype)) {
1821                                 continue;
1822                         }
1823
1824                         $filetype = strtolower(substr($mediatype, 0, strpos($mediatype, '/')));
1825
1826                         if ($filetype == 'audio') {
1827                                 $attachments[] = ['type' => $filetype, 'mediaType' => $mediatype, 'url' => $href, 'height' => null, 'size' => null, 'name' => ''];
1828                         } elseif ($filetype == 'video') {
1829                                 $height = (int)JsonLD::fetchElement($url, 'as:height', '@value');
1830                                 // PeerTube audio-only track
1831                                 if ($height === 0) {
1832                                         continue;
1833                                 }
1834
1835                                 $size = (int)JsonLD::fetchElement($url, 'pt:size', '@value');
1836                                 $attachments[] = ['type' => $filetype, 'mediaType' => $mediatype, 'url' => $href, 'height' => $height, 'size' => $size, 'name' => ''];
1837                         } elseif (in_array($mediatype, ['application/x-bittorrent', 'application/x-bittorrent;x-scheme-handler/magnet'])) {
1838                                 $height = (int)JsonLD::fetchElement($url, 'as:height', '@value');
1839
1840                                 // For Torrent links we always store the highest resolution
1841                                 if (!empty($attachments[$mediatype]['height']) && ($height < $attachments[$mediatype]['height'])) {
1842                                         continue;
1843                                 }
1844
1845                                 $attachments[$mediatype] = ['type' => $mediatype, 'mediaType' => $mediatype, 'url' => $href, 'height' => $height, 'size' => null, 'name' => ''];
1846                         } elseif ($mediatype == 'application/x-mpegURL') {
1847                                 // PeerTube exception, actual video link is in the tags of this URL element
1848                                 $attachments = array_merge($attachments, self::processAttachmentUrls($url['as:tag']));
1849                         }
1850                 }
1851
1852                 return array_values($attachments);
1853         }
1854
1855         /**
1856          * Fetches data from the object part of an activity
1857          *
1858          * @param array $object
1859          *
1860          * @return array|bool Object data or FALSE if $object does not contain @id element
1861          * @throws \Exception
1862          */
1863         private static function processObject(array $object)
1864         {
1865                 if (!JsonLD::fetchElement($object, '@id')) {
1866                         return false;
1867                 }
1868
1869                 $object_data = self::getObjectDataFromActivity($object);
1870
1871                 $receiverdata = self::getReceivers($object, $object_data['actor'] ?? '', $object_data['tags'], true, false);
1872                 $receivers = $reception_types = [];
1873                 foreach ($receiverdata as $key => $data) {
1874                         $receivers[$key] = $data['uid'];
1875                         $reception_types[$data['uid']] = $data['type'] ?? 0;
1876                 }
1877
1878                 $object_data['receiver_urls']  = self::getReceiverURL($object);
1879                 $object_data['receiver']       = $receivers;
1880                 $object_data['reception_type'] = $reception_types;
1881
1882                 $object_data['unlisted'] = in_array(-1, $object_data['receiver']);
1883                 unset($object_data['receiver'][-1]);
1884                 unset($object_data['reception_type'][-1]);
1885
1886                 return $object_data;
1887         }
1888
1889         /**
1890          * Create an object data array from a given activity
1891          *
1892          * @param array $object
1893          *
1894          * @return array Object data
1895          */
1896         public static function getObjectDataFromActivity(array $object): array
1897         {
1898                 $object_data = [];
1899                 $object_data['object_type'] = JsonLD::fetchElement($object, '@type');
1900                 $object_data['id'] = JsonLD::fetchElement($object, '@id');
1901                 $object_data['reply-to-id'] = JsonLD::fetchElement($object, 'as:inReplyTo', '@id');
1902
1903                 // An empty "id" field is translated to "./" by the compactor, so we have to check for this content
1904                 if (empty($object_data['reply-to-id']) || ($object_data['reply-to-id'] == './')) {
1905                         $object_data['reply-to-id'] = $object_data['id'];
1906
1907                         // On activities the "reply to" is the id of the object it refers to
1908                         if (in_array($object_data['object_type'], array_merge(self::ACTIVITY_TYPES, ['as:Announce']))) {
1909                                 $object_id = JsonLD::fetchElement($object, 'as:object', '@id');
1910                                 if (!empty($object_id)) {
1911                                         $object_data['reply-to-id'] = $object_id;
1912                                 }
1913                         }
1914                 } else {
1915                         // Some systems (e.g. GNU Social) don't reply to the "id" field but the "uri" field.
1916                         $replyToId = Item::getURIByLink($object_data['reply-to-id']);
1917                         if (!empty($replyToId) && ($object_data['reply-to-id'] != $replyToId)) {
1918                                 Logger::notice('Fix wrong reply-to', ['received' => $object_data['reply-to-id'], 'correct' => $replyToId]);
1919                                 $object_data['reply-to-id'] = $replyToId;
1920                         }
1921                 }
1922
1923                 $object_data['published'] = JsonLD::fetchElement($object, 'as:published', '@value');
1924                 $object_data['updated'] = JsonLD::fetchElement($object, 'as:updated', '@value');
1925
1926                 if (empty($object_data['updated'])) {
1927                         $object_data['updated'] = $object_data['published'];
1928                 }
1929
1930                 if (empty($object_data['published']) && !empty($object_data['updated'])) {
1931                         $object_data['published'] = $object_data['updated'];
1932                 }
1933
1934                 $actor = JsonLD::fetchElement($object, 'as:attributedTo', '@id');
1935                 if (empty($actor)) {
1936                         $actor = JsonLD::fetchElement($object, 'as:actor', '@id');
1937                 }
1938
1939                 $location = JsonLD::fetchElement($object, 'as:location', 'as:name', '@type', 'as:Place');
1940                 $location = JsonLD::fetchElement($location, 'location', '@value');
1941                 if ($location) {
1942                         // Some AP software allow formatted text in post location, so we run all the text converters we have to boil
1943                         // down to HTML and then finally format to plaintext.
1944                         $location = Markdown::convert($location);
1945                         $location = BBCode::toPlaintext($location);
1946                 }
1947
1948                 $object_data['sc:identifier'] = JsonLD::fetchElement($object, 'sc:identifier', '@value');
1949                 $object_data['diaspora:guid'] = JsonLD::fetchElement($object, 'diaspora:guid', '@value');
1950                 $object_data['diaspora:comment'] = JsonLD::fetchElement($object, 'diaspora:comment', '@value');
1951                 $object_data['diaspora:like'] = JsonLD::fetchElement($object, 'diaspora:like', '@value');
1952                 $object_data['actor'] = $object_data['author'] = $actor;
1953                 $element = JsonLD::fetchElement($object, 'as:context', '@id');
1954                 $object_data['context'] = $element != './' ? $element : null;
1955                 $element = JsonLD::fetchElement($object, 'ostatus:conversation', '@id');
1956                 $object_data['conversation'] = $element != './' ? $element : null;
1957                 $object_data['sensitive'] = JsonLD::fetchElement($object, 'as:sensitive');
1958                 $object_data['name'] = JsonLD::fetchElement($object, 'as:name', '@value');
1959                 $object_data['summary'] = JsonLD::fetchElement($object, 'as:summary', '@value');
1960                 $object_data['content'] = JsonLD::fetchElement($object, 'as:content', '@value');
1961                 $object_data['mediatype'] = JsonLD::fetchElement($object, 'as:mediaType', '@value');
1962                 $object_data = self::getSource($object, $object_data);
1963                 $object_data['start-time'] = JsonLD::fetchElement($object, 'as:startTime', '@value');
1964                 $object_data['end-time'] = JsonLD::fetchElement($object, 'as:endTime', '@value');
1965                 $object_data['location'] = $location;
1966                 $object_data['latitude'] = JsonLD::fetchElement($object, 'as:location', 'as:latitude', '@type', 'as:Place');
1967                 $object_data['latitude'] = JsonLD::fetchElement($object_data, 'latitude', '@value');
1968                 $object_data['longitude'] = JsonLD::fetchElement($object, 'as:location', 'as:longitude', '@type', 'as:Place');
1969                 $object_data['longitude'] = JsonLD::fetchElement($object_data, 'longitude', '@value');
1970                 $object_data['attachments'] = self::processAttachments(JsonLD::fetchElementArray($object, 'as:attachment') ?? []);
1971                 $object_data['tags'] = self::processTags(JsonLD::fetchElementArray($object, 'as:tag') ?? []);
1972                 $object_data['emojis'] = self::processEmojis(JsonLD::fetchElementArray($object, 'as:tag', null, '@type', 'toot:Emoji') ?? []);
1973                 $object_data['languages'] = self::processLanguages(JsonLD::fetchElementArray($object, 'sc:inLanguage') ?? []);
1974                 $object_data['generator'] = JsonLD::fetchElement($object, 'as:generator', 'as:name', '@type', 'as:Application');
1975                 $object_data['generator'] = JsonLD::fetchElement($object_data, 'generator', '@value');
1976                 $object_data['alternate-url'] = JsonLD::fetchElement($object, 'as:url', '@id');
1977
1978                 // Special treatment for Hubzilla links
1979                 if (is_array($object_data['alternate-url'])) {
1980                         $object_data['alternate-url'] = JsonLD::fetchElement($object_data['alternate-url'], 'as:href', '@id');
1981
1982                         if (!is_string($object_data['alternate-url'])) {
1983                                 $object_data['alternate-url'] = JsonLD::fetchElement($object['as:url'], 'as:href', '@id');
1984                         }
1985                 }
1986
1987                 if (!empty($object_data['alternate-url']) && !Network::isValidHttpUrl($object_data['alternate-url'])) {
1988                         $object_data['alternate-url'] = null;
1989                 }
1990
1991                 if (in_array($object_data['object_type'], ['as:Audio', 'as:Video'])) {
1992                         $object_data['alternate-url'] = self::extractAlternateUrl($object['as:url'] ?? []) ?: $object_data['alternate-url'];
1993                         $object_data['attachments'] = array_merge($object_data['attachments'], self::processAttachmentUrls($object['as:url'] ?? []));
1994                 }
1995
1996                 // Support for quoted posts (Pleroma, Fedibird and Misskey)
1997                 $object_data['quote-url'] = JsonLD::fetchElement($object, 'as:quoteUrl', '@value');
1998                 if (empty($object_data['quote-url'])) {
1999                         $object_data['quote-url'] = JsonLD::fetchElement($object, 'fedibird:quoteUri', '@value');
2000                 }
2001                 if (empty($object_data['quote-url'])) {
2002                         $object_data['quote-url'] = JsonLD::fetchElement($object, 'misskey:_misskey_quote', '@value');
2003                 }
2004
2005                 // Misskey adds some data to the standard "content" value for quoted posts for backwards compatibility.
2006                 // Their own "_misskey_content" value does then contain the content without this extra data.
2007                 if (!empty($object_data['quote-url'])) {
2008                         $misskey_content = JsonLD::fetchElement($object, 'misskey:_misskey_content', '@value');
2009                         if (!empty($misskey_content)) {
2010                                 $object_data['content'] = $misskey_content;
2011                         }
2012                 }
2013
2014                 // For page types we expect that the alternate url posts to some page.
2015                 // So we add this to the attachments if it differs from the id.
2016                 // Currently only Lemmy is using the page type.
2017                 if (($object_data['object_type'] == 'as:Page') && !empty($object_data['alternate-url']) && !Strings::compareLink($object_data['alternate-url'], $object_data['id'])) {
2018                         $object_data['attachments'][] = ['url' => $object_data['alternate-url']];
2019                         $object_data['alternate-url'] = null;
2020                 }
2021
2022                 if ($object_data['object_type'] == 'as:Question') {
2023                         $object_data['question'] = self::processQuestion($object);
2024                 }
2025
2026                 return $object_data;
2027         }
2028
2029         /**
2030          * Add an object id to the list of arrived activities
2031          *
2032          * @param string $id
2033          *
2034          * @return void
2035          */
2036         private static function addArrivedId(string $id)
2037         {
2038                 DBA::delete('arrived-activity', ["`received` < ?", DateTimeFormat::utc('now - 5 minutes')]);
2039                 DBA::insert('arrived-activity', ['object-id' => $id, 'received' => DateTimeFormat::utcNow()], Database::INSERT_IGNORE);
2040         }
2041
2042         /**
2043          * Checks if the given object already arrived before
2044          *
2045          * @param string $id
2046          *
2047          * @return boolean
2048          */
2049         private static function hasArrived(string $id): bool
2050         {
2051                 return DBA::exists('arrived-activity', ['object-id' => $id]);
2052         }
2053 }