]> git.mxchange.org Git - friendica.git/blob - src/Protocol/ActivityPub/Transmitter.php
b371c7e59dce9da8a774144e6d7bc4bd4c5b1d1a
[friendica.git] / src / Protocol / ActivityPub / Transmitter.php
1 <?php
2 /**
3  * @copyright Copyright (C) 2020, Friendica
4  *
5  * @license GNU AGPL version 3 or any later version
6  *
7  * This program is free software: you can redistribute it and/or modify
8  * it under the terms of the GNU Affero General Public License as
9  * published by the Free Software Foundation, either version 3 of the
10  * License, or (at your option) any later version.
11  *
12  * This program is distributed in the hope that it will be useful,
13  * but WITHOUT ANY WARRANTY; without even the implied warranty of
14  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15  * GNU Affero General Public License for more details.
16  *
17  * You should have received a copy of the GNU Affero General Public License
18  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
19  *
20  */
21
22 namespace Friendica\Protocol\ActivityPub;
23
24 use Friendica\Content\Feature;
25 use Friendica\Content\Text\BBCode;
26 use Friendica\Content\Text\Plaintext;
27 use Friendica\Core\Cache\Duration;
28 use Friendica\Core\Logger;
29 use Friendica\Core\Protocol;
30 use Friendica\Core\System;
31 use Friendica\Database\DBA;
32 use Friendica\DI;
33 use Friendica\Model\APContact;
34 use Friendica\Model\Contact;
35 use Friendica\Model\Conversation;
36 use Friendica\Model\Item;
37 use Friendica\Model\Profile;
38 use Friendica\Model\Photo;
39 use Friendica\Model\Term;
40 use Friendica\Model\User;
41 use Friendica\Protocol\Activity;
42 use Friendica\Protocol\ActivityPub;
43 use Friendica\Util\DateTimeFormat;
44 use Friendica\Util\HTTPSignature;
45 use Friendica\Util\Images;
46 use Friendica\Util\JsonLD;
47 use Friendica\Util\LDSignature;
48 use Friendica\Util\Map;
49 use Friendica\Util\Network;
50 use Friendica\Util\XML;
51
52 require_once 'include/api.php';
53 require_once 'mod/share.php';
54
55 /**
56  * ActivityPub Transmitter Protocol class
57  *
58  * To-Do:
59  * @todo Undo Announce
60  */
61 class Transmitter
62 {
63         /**
64          * collects the lost of followers of the given owner
65          *
66          * @param array   $owner Owner array
67          * @param integer $page  Page number
68          *
69          * @return array of owners
70          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
71          */
72         public static function getFollowers($owner, $page = null)
73         {
74                 $condition = ['rel' => [Contact::FOLLOWER, Contact::FRIEND], 'network' => Protocol::NATIVE_SUPPORT, 'uid' => $owner['uid'],
75                         'self' => false, 'deleted' => false, 'hidden' => false, 'archive' => false, 'pending' => false];
76                 $count = DBA::count('contact', $condition);
77
78                 $data = ['@context' => ActivityPub::CONTEXT];
79                 $data['id'] = DI::baseUrl() . '/followers/' . $owner['nickname'];
80                 $data['type'] = 'OrderedCollection';
81                 $data['totalItems'] = $count;
82
83                 // When we hide our friends we will only show the pure number but don't allow more.
84                 $profile = Profile::getByUID($owner['uid']);
85                 if (!empty($profile['hide-friends'])) {
86                         return $data;
87                 }
88
89                 if (empty($page)) {
90                         $data['first'] = DI::baseUrl() . '/followers/' . $owner['nickname'] . '?page=1';
91                 } else {
92                         $data['type'] = 'OrderedCollectionPage';
93                         $list = [];
94
95                         $contacts = DBA::select('contact', ['url'], $condition, ['limit' => [($page - 1) * 100, 100]]);
96                         while ($contact = DBA::fetch($contacts)) {
97                                 $list[] = $contact['url'];
98                         }
99
100                         if (!empty($list)) {
101                                 $data['next'] = DI::baseUrl() . '/followers/' . $owner['nickname'] . '?page=' . ($page + 1);
102                         }
103
104                         $data['partOf'] = DI::baseUrl() . '/followers/' . $owner['nickname'];
105
106                         $data['orderedItems'] = $list;
107                 }
108
109                 return $data;
110         }
111
112         /**
113          * Create list of following contacts
114          *
115          * @param array   $owner Owner array
116          * @param integer $page  Page numbe
117          *
118          * @return array of following contacts
119          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
120          */
121         public static function getFollowing($owner, $page = null)
122         {
123                 $condition = ['rel' => [Contact::SHARING, Contact::FRIEND], 'network' => Protocol::NATIVE_SUPPORT, 'uid' => $owner['uid'],
124                         'self' => false, 'deleted' => false, 'hidden' => false, 'archive' => false, 'pending' => false];
125                 $count = DBA::count('contact', $condition);
126
127                 $data = ['@context' => ActivityPub::CONTEXT];
128                 $data['id'] = DI::baseUrl() . '/following/' . $owner['nickname'];
129                 $data['type'] = 'OrderedCollection';
130                 $data['totalItems'] = $count;
131
132                 // When we hide our friends we will only show the pure number but don't allow more.
133                 $profile = Profile::getByUID($owner['uid']);
134                 if (!empty($profile['hide-friends'])) {
135                         return $data;
136                 }
137
138                 if (empty($page)) {
139                         $data['first'] = DI::baseUrl() . '/following/' . $owner['nickname'] . '?page=1';
140                 } else {
141                         $data['type'] = 'OrderedCollectionPage';
142                         $list = [];
143
144                         $contacts = DBA::select('contact', ['url'], $condition, ['limit' => [($page - 1) * 100, 100]]);
145                         while ($contact = DBA::fetch($contacts)) {
146                                 $list[] = $contact['url'];
147                         }
148
149                         if (!empty($list)) {
150                                 $data['next'] = DI::baseUrl() . '/following/' . $owner['nickname'] . '?page=' . ($page + 1);
151                         }
152
153                         $data['partOf'] = DI::baseUrl() . '/following/' . $owner['nickname'];
154
155                         $data['orderedItems'] = $list;
156                 }
157
158                 return $data;
159         }
160
161         /**
162          * Public posts for the given owner
163          *
164          * @param array   $owner Owner array
165          * @param integer $page  Page numbe
166          *
167          * @return array of posts
168          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
169          * @throws \ImagickException
170          */
171         public static function getOutbox($owner, $page = null)
172         {
173                 $public_contact = Contact::getIdForURL($owner['url'], 0, true);
174
175                 $condition = ['uid' => 0, 'contact-id' => $public_contact, 'author-id' => $public_contact,
176                         'private' => false, 'gravity' => [GRAVITY_PARENT, GRAVITY_COMMENT],
177                         'deleted' => false, 'visible' => true, 'moderated' => false];
178                 $count = DBA::count('item', $condition);
179
180                 $data = ['@context' => ActivityPub::CONTEXT];
181                 $data['id'] = DI::baseUrl() . '/outbox/' . $owner['nickname'];
182                 $data['type'] = 'OrderedCollection';
183                 $data['totalItems'] = $count;
184
185                 if (empty($page)) {
186                         $data['first'] = DI::baseUrl() . '/outbox/' . $owner['nickname'] . '?page=1';
187                 } else {
188                         $data['type'] = 'OrderedCollectionPage';
189                         $list = [];
190
191                         $condition['parent-network'] = Protocol::NATIVE_SUPPORT;
192
193                         $items = Item::select(['id'], $condition, ['limit' => [($page - 1) * 20, 20], 'order' => ['created' => true]]);
194                         while ($item = Item::fetch($items)) {
195                                 $activity = self::createActivityFromItem($item['id'], true);
196                                 // Only list "Create" activity objects here, no reshares
197                                 if (is_array($activity['object']) && ($activity['type'] == 'Create')) {
198                                         $list[] = $activity['object'];
199                                 }
200                         }
201
202                         if (!empty($list)) {
203                                 $data['next'] = DI::baseUrl() . '/outbox/' . $owner['nickname'] . '?page=' . ($page + 1);
204                         }
205
206                         $data['partOf'] = DI::baseUrl() . '/outbox/' . $owner['nickname'];
207
208                         $data['orderedItems'] = $list;
209                 }
210
211                 return $data;
212         }
213
214         /**
215          * Return the service array containing information the used software and it's url
216          *
217          * @return array with service data
218          */
219         private static function getService()
220         {
221                 return ['type' => 'Service',
222                         'name' =>  FRIENDICA_PLATFORM . " '" . FRIENDICA_CODENAME . "' " . FRIENDICA_VERSION . '-' . DB_UPDATE_VERSION,
223                         'url' => DI::baseUrl()->get()];
224         }
225
226         /**
227          * Return the ActivityPub profile of the given user
228          *
229          * @param integer $uid User ID
230          * @return array with profile data
231          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
232          */
233         public static function getProfile($uid)
234         {
235                 $condition = ['uid' => $uid, 'blocked' => false, 'account_expired' => false,
236                         'account_removed' => false, 'verified' => true];
237                 $fields = ['guid', 'nickname', 'pubkey', 'account-type', 'page-flags'];
238                 $user = DBA::selectFirst('user', $fields, $condition);
239                 if (!DBA::isResult($user)) {
240                         return [];
241                 }
242
243                 $fields = ['locality', 'region', 'country-name'];
244                 $profile = DBA::selectFirst('profile', $fields, ['uid' => $uid]);
245                 if (!DBA::isResult($profile)) {
246                         return [];
247                 }
248
249                 $fields = ['name', 'url', 'location', 'about', 'avatar', 'photo'];
250                 $contact = DBA::selectFirst('contact', $fields, ['uid' => $uid, 'self' => true]);
251                 if (!DBA::isResult($contact)) {
252                         return [];
253                 }
254
255                 $data = ['@context' => ActivityPub::CONTEXT];
256                 $data['id'] = $contact['url'];
257                 $data['diaspora:guid'] = $user['guid'];
258                 $data['type'] = ActivityPub::ACCOUNT_TYPES[$user['account-type']];
259                 $data['following'] = DI::baseUrl() . '/following/' . $user['nickname'];
260                 $data['followers'] = DI::baseUrl() . '/followers/' . $user['nickname'];
261                 $data['inbox'] = DI::baseUrl() . '/inbox/' . $user['nickname'];
262                 $data['outbox'] = DI::baseUrl() . '/outbox/' . $user['nickname'];
263                 $data['preferredUsername'] = $user['nickname'];
264                 $data['name'] = $contact['name'];
265                 $data['vcard:hasAddress'] = ['@type' => 'vcard:Home', 'vcard:country-name' => $profile['country-name'],
266                         'vcard:region' => $profile['region'], 'vcard:locality' => $profile['locality']];
267                 $data['summary'] = BBCode::convert($contact['about'], false, 9);
268                 $data['url'] = $contact['url'];
269                 $data['manuallyApprovesFollowers'] = in_array($user['page-flags'], [User::PAGE_FLAGS_NORMAL, User::PAGE_FLAGS_PRVGROUP]);
270                 $data['publicKey'] = ['id' => $contact['url'] . '#main-key',
271                         'owner' => $contact['url'],
272                         'publicKeyPem' => $user['pubkey']];
273                 $data['endpoints'] = ['sharedInbox' => DI::baseUrl() . '/inbox'];
274                 $data['icon'] = ['type' => 'Image',
275                         'url' => $contact['photo']];
276
277                 $data['generator'] = self::getService();
278
279                 // tags: https://kitty.town/@inmysocks/100656097926961126.json
280                 return $data;
281         }
282
283         /**
284          * @param string $username
285          * @return array
286          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
287          */
288         public static function getDeletedUser($username)
289         {
290                 return [
291                         '@context' => ActivityPub::CONTEXT,
292                         'id' => DI::baseUrl() . '/profile/' . $username,
293                         'type' => 'Tombstone',
294                         'published' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
295                         'updated' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
296                         'deleted' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
297                 ];
298         }
299
300         /**
301          * Returns an array with permissions of a given item array
302          *
303          * @param array $item
304          *
305          * @return array with permissions
306          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
307          * @throws \ImagickException
308          */
309         private static function fetchPermissionBlockFromConversation($item)
310         {
311                 if (empty($item['thr-parent'])) {
312                         return [];
313                 }
314
315                 $condition = ['item-uri' => $item['thr-parent'], 'protocol' => Conversation::PARCEL_ACTIVITYPUB];
316                 $conversation = DBA::selectFirst('conversation', ['source'], $condition);
317                 if (!DBA::isResult($conversation)) {
318                         return [];
319                 }
320
321                 $activity = json_decode($conversation['source'], true);
322
323                 $actor = JsonLD::fetchElement($activity, 'actor', 'id');
324                 $profile = APContact::getByURL($actor);
325
326                 $item_profile = APContact::getByURL($item['author-link']);
327                 $exclude[] = $item['author-link'];
328
329                 if ($item['gravity'] == GRAVITY_PARENT) {
330                         $exclude[] = $item['owner-link'];
331                 }
332
333                 $permissions['to'][] = $actor;
334
335                 foreach (['to', 'cc', 'bto', 'bcc'] as $element) {
336                         if (empty($activity[$element])) {
337                                 continue;
338                         }
339                         if (is_string($activity[$element])) {
340                                 $activity[$element] = [$activity[$element]];
341                         }
342
343                         foreach ($activity[$element] as $receiver) {
344                                 if ($receiver == $profile['followers'] && !empty($item_profile['followers'])) {
345                                         $permissions[$element][] = $item_profile['followers'];
346                                 } elseif (!in_array($receiver, $exclude)) {
347                                         $permissions[$element][] = $receiver;
348                                 }
349                         }
350                 }
351                 return $permissions;
352         }
353
354         /**
355          * Creates an array of permissions from an item thread
356          *
357          * @param array   $item       Item array
358          * @param boolean $blindcopy  addressing via "bcc" or "cc"?
359          * @param integer $last_id    Last item id for adding receivers
360          * @param boolean $forum_mode "true" means that we are sending content to a forum
361          *
362          * @return array with permission data
363          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
364          * @throws \ImagickException
365          */
366         private static function createPermissionBlockForItem($item, $blindcopy, $last_id = 0, $forum_mode = false)
367         {
368                 if ($last_id == 0) {
369                         $last_id = $item['id'];
370                 }
371
372                 $always_bcc = false;
373
374                 // Check if we should always deliver our stuff via BCC
375                 if (!empty($item['uid'])) {
376                         $profile = Profile::getByUID($item['uid']);
377                         if (!empty($profile)) {
378                                 $always_bcc = $profile['hide-friends'];
379                         }
380                 }
381
382                 if (DI::config()->get('system', 'ap_always_bcc')) {
383                         $always_bcc = true;
384                 }
385
386                 if (self::isAnnounce($item) || DI::config()->get('debug', 'total_ap_delivery')) {
387                         // Will be activated in a later step
388                         $networks = Protocol::FEDERATED;
389                 } else {
390                         // For now only send to these contacts:
391                         $networks = [Protocol::ACTIVITYPUB, Protocol::OSTATUS];
392                 }
393
394                 $data = ['to' => [], 'cc' => [], 'bcc' => []];
395
396                 if ($item['gravity'] == GRAVITY_PARENT) {
397                         $actor_profile = APContact::getByURL($item['owner-link']);
398                 } else {
399                         $actor_profile = APContact::getByURL($item['author-link']);
400                 }
401
402                 $terms = Term::tagArrayFromItemId($item['id'], [Term::MENTION, Term::IMPLICIT_MENTION]);
403
404                 if (!$item['private']) {
405                         // Directly mention the original author upon a quoted reshare.
406                         // Else just ensure that the original author receives the reshare.
407                         $announce = self::getAnnounceArray($item);
408                         if (!empty($announce['comment'])) {
409                                 $data['to'][] = $announce['actor']['url'];
410                         } elseif (!empty($announce)) {
411                                 $data['cc'][] = $announce['actor']['url'];
412                         }
413
414                         $data = array_merge($data, self::fetchPermissionBlockFromConversation($item));
415
416                         $data['to'][] = ActivityPub::PUBLIC_COLLECTION;
417
418                         foreach ($terms as $term) {
419                                 $profile = APContact::getByURL($term['url'], false);
420                                 if (!empty($profile)) {
421                                         $data['to'][] = $profile['url'];
422                                 }
423                         }
424                 } else {
425                         $receiver_list = Item::enumeratePermissions($item, true);
426
427                         foreach ($terms as $term) {
428                                 $cid = Contact::getIdForURL($term['url'], $item['uid']);
429                                 if (!empty($cid) && in_array($cid, $receiver_list)) {
430                                         $contact = DBA::selectFirst('contact', ['url', 'network', 'protocol'], ['id' => $cid]);
431                                         if (!DBA::isResult($contact) || (!in_array($contact['network'], $networks) && ($contact['protocol'] != Protocol::ACTIVITYPUB))) {
432                                                 continue;
433                                         }
434
435                                         if (!empty($profile = APContact::getByURL($contact['url'], false))) {
436                                                 $data['to'][] = $profile['url'];
437                                         }
438                                 }
439                         }
440
441                         foreach ($receiver_list as $receiver) {
442                                 $contact = DBA::selectFirst('contact', ['url', 'hidden', 'network', 'protocol'], ['id' => $receiver]);
443                                 if (!DBA::isResult($contact) || (!in_array($contact['network'], $networks) && ($contact['protocol'] != Protocol::ACTIVITYPUB))) {
444                                         continue;
445                                 }
446
447                                 if (!empty($profile = APContact::getByURL($contact['url'], false))) {
448                                         if ($contact['hidden'] || $always_bcc) {
449                                                 $data['bcc'][] = $profile['url'];
450                                         } else {
451                                                 $data['cc'][] = $profile['url'];
452                                         }
453                                 }
454                         }
455                 }
456
457                 if (!empty($item['parent'])) {
458                         $parents = Item::select(['id', 'author-link', 'owner-link', 'gravity', 'uri'], ['parent' => $item['parent']]);
459                         while ($parent = Item::fetch($parents)) {
460                                 if ($parent['gravity'] == GRAVITY_PARENT) {
461                                         $profile = APContact::getByURL($parent['owner-link'], false);
462                                         if (!empty($profile)) {
463                                                 if ($item['gravity'] != GRAVITY_PARENT) {
464                                                         // Comments to forums are directed to the forum
465                                                         // But comments to forums aren't directed to the followers collection
466                                                         if ($profile['type'] == 'Group') {
467                                                                 $data['to'][] = $profile['url'];
468                                                         } else {
469                                                                 $data['cc'][] = $profile['url'];
470                                                                 if (!$item['private'] && !empty($actor_profile['followers'])) {
471                                                                         $data['cc'][] = $actor_profile['followers'];
472                                                                 }
473                                                         }
474                                                 } else {
475                                                         // Public thread parent post always are directed to the followers
476                                                         if (!$item['private'] && !$forum_mode) {
477                                                                 $data['cc'][] = $actor_profile['followers'];
478                                                         }
479                                                 }
480                                         }
481                                 }
482
483                                 // Don't include data from future posts
484                                 if ($parent['id'] >= $last_id) {
485                                         continue;
486                                 }
487
488                                 $profile = APContact::getByURL($parent['author-link'], false);
489                                 if (!empty($profile)) {
490                                         if (($profile['type'] == 'Group') || ($parent['uri'] == $item['thr-parent'])) {
491                                                 $data['to'][] = $profile['url'];
492                                         } else {
493                                                 $data['cc'][] = $profile['url'];
494                                         }
495                                 }
496                         }
497                         DBA::close($parents);
498                 }
499
500                 $data['to'] = array_unique($data['to']);
501                 $data['cc'] = array_unique($data['cc']);
502                 $data['bcc'] = array_unique($data['bcc']);
503
504                 if (($key = array_search($item['author-link'], $data['to'])) !== false) {
505                         unset($data['to'][$key]);
506                 }
507
508                 if (($key = array_search($item['author-link'], $data['cc'])) !== false) {
509                         unset($data['cc'][$key]);
510                 }
511
512                 if (($key = array_search($item['author-link'], $data['bcc'])) !== false) {
513                         unset($data['bcc'][$key]);
514                 }
515
516                 foreach ($data['to'] as $to) {
517                         if (($key = array_search($to, $data['cc'])) !== false) {
518                                 unset($data['cc'][$key]);
519                         }
520
521                         if (($key = array_search($to, $data['bcc'])) !== false) {
522                                 unset($data['bcc'][$key]);
523                         }
524                 }
525
526                 foreach ($data['cc'] as $cc) {
527                         if (($key = array_search($cc, $data['bcc'])) !== false) {
528                                 unset($data['bcc'][$key]);
529                         }
530                 }
531
532                 $receivers = ['to' => array_values($data['to']), 'cc' => array_values($data['cc']), 'bcc' => array_values($data['bcc'])];
533
534                 if (!$blindcopy) {
535                         unset($receivers['bcc']);
536                 }
537
538                 return $receivers;
539         }
540
541         /**
542          * Check if an inbox is archived
543          *
544          * @param string $url Inbox url
545          *
546          * @return boolean "true" if inbox is archived
547          */
548         private static function archivedInbox($url)
549         {
550                 return DBA::exists('inbox-status', ['url' => $url, 'archive' => true]);
551         }
552
553         /**
554          * Fetches a list of inboxes of followers of a given user
555          *
556          * @param integer $uid      User ID
557          * @param boolean $personal fetch personal inboxes
558          *
559          * @return array of follower inboxes
560          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
561          * @throws \ImagickException
562          */
563         public static function fetchTargetInboxesforUser($uid, $personal = false)
564         {
565                 $inboxes = [];
566
567                 if (DI::config()->get('debug', 'total_ap_delivery')) {
568                         // Will be activated in a later step
569                         $networks = Protocol::FEDERATED;
570                 } else {
571                         // For now only send to these contacts:
572                         $networks = [Protocol::ACTIVITYPUB, Protocol::OSTATUS];
573                 }
574
575                 $condition = ['uid' => $uid, 'archive' => false, 'pending' => false];
576
577                 if (!empty($uid)) {
578                         $condition['rel'] = [Contact::FOLLOWER, Contact::FRIEND];
579                 }
580
581                 $contacts = DBA::select('contact', ['url', 'network', 'protocol'], $condition);
582                 while ($contact = DBA::fetch($contacts)) {
583                         if (Contact::isLocal($contact['url'])) {
584                                 continue;
585                         }
586
587                         if (!in_array($contact['network'], $networks) && ($contact['protocol'] != Protocol::ACTIVITYPUB)) {
588                                 continue;
589                         }
590
591                         if (Network::isUrlBlocked($contact['url'])) {
592                                 continue;
593                         }
594
595                         $profile = APContact::getByURL($contact['url'], false);
596                         if (!empty($profile)) {
597                                 if (empty($profile['sharedinbox']) || $personal) {
598                                         $target = $profile['inbox'];
599                                 } else {
600                                         $target = $profile['sharedinbox'];
601                                 }
602                                 if (!self::archivedInbox($target)) {
603                                         $inboxes[$target] = $target;
604                                 }
605                         }
606                 }
607                 DBA::close($contacts);
608
609                 return $inboxes;
610         }
611
612         /**
613          * Fetches an array of inboxes for the given item and user
614          *
615          * @param array   $item       Item array
616          * @param integer $uid        User ID
617          * @param boolean $personal   fetch personal inboxes
618          * @param integer $last_id    Last item id for adding receivers
619          * @param boolean $forum_mode "true" means that we are sending content to a forum
620          * @return array with inboxes
621          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
622          * @throws \ImagickException
623          */
624         public static function fetchTargetInboxes($item, $uid, $personal = false, $last_id = 0, $forum_mode = false)
625         {
626                 $permissions = self::createPermissionBlockForItem($item, true, $last_id, $forum_mode);
627                 if (empty($permissions)) {
628                         return [];
629                 }
630
631                 $inboxes = [];
632
633                 if ($item['gravity'] == GRAVITY_ACTIVITY) {
634                         $item_profile = APContact::getByURL($item['author-link'], false);
635                 } else {
636                         $item_profile = APContact::getByURL($item['owner-link'], false);
637                 }
638
639                 foreach (['to', 'cc', 'bto', 'bcc'] as $element) {
640                         if (empty($permissions[$element])) {
641                                 continue;
642                         }
643
644                         $blindcopy = in_array($element, ['bto', 'bcc']);
645
646                         foreach ($permissions[$element] as $receiver) {
647                                 if (Network::isUrlBlocked($receiver)) {
648                                         continue;
649                                 }
650
651                                 if ($receiver == $item_profile['followers']) {
652                                         $inboxes = array_merge($inboxes, self::fetchTargetInboxesforUser($uid, $personal));
653                                 } else {
654                                         if (Contact::isLocal($receiver)) {
655                                                 continue;
656                                         }
657
658                                         $profile = APContact::getByURL($receiver, false);
659                                         if (!empty($profile)) {
660                                                 if (empty($profile['sharedinbox']) || $personal || $blindcopy) {
661                                                         $target = $profile['inbox'];
662                                                 } else {
663                                                         $target = $profile['sharedinbox'];
664                                                 }
665                                                 if (!self::archivedInbox($target)) {
666                                                         $inboxes[$target] = $target;
667                                                 }
668                                         }
669                                 }
670                         }
671                 }
672
673                 return $inboxes;
674         }
675
676         /**
677          * Creates an array in the structure of the item table for a given mail id
678          *
679          * @param integer $mail_id
680          *
681          * @return array
682          * @throws \Exception
683          */
684         public static function ItemArrayFromMail($mail_id)
685         {
686                 $mail = DBA::selectFirst('mail', [], ['id' => $mail_id]);
687                 if (!DBA::isResult($mail)) {
688                         return [];
689                 }
690
691                 $reply = DBA::selectFirst('mail', ['uri'], ['parent-uri' => $mail['parent-uri'], 'reply' => false]);
692
693                 // Making the post more compatible for Mastodon by:
694                 // - Making it a note and not an article (no title)
695                 // - Moving the title into the "summary" field that is used as a "content warning"
696                 $mail['body'] = '[abstract]' . $mail['title'] . "[/abstract]\n" . $mail['body'];
697                 $mail['title'] = '';
698
699                 $mail['author-link'] = $mail['owner-link'] = $mail['from-url'];
700                 $mail['allow_cid'] = '<'.$mail['contact-id'].'>';
701                 $mail['allow_gid'] = '';
702                 $mail['deny_cid'] = '';
703                 $mail['deny_gid'] = '';
704                 $mail['private'] = true;
705                 $mail['deleted'] = false;
706                 $mail['edited'] = $mail['created'];
707                 $mail['plink'] = $mail['uri'];
708                 $mail['thr-parent'] = $reply['uri'];
709                 $mail['gravity'] = ($mail['reply'] ? GRAVITY_COMMENT: GRAVITY_PARENT);
710
711                 $mail['event-type'] = '';
712                 $mail['attach'] = '';
713
714                 $mail['parent'] = 0;
715
716                 return $mail;
717         }
718
719         /**
720          * Creates an activity array for a given mail id
721          *
722          * @param integer $mail_id
723          * @param boolean $object_mode Is the activity item is used inside another object?
724          *
725          * @return array of activity
726          * @throws \Exception
727          */
728         public static function createActivityFromMail($mail_id, $object_mode = false)
729         {
730                 $mail = self::ItemArrayFromMail($mail_id);
731                 $object = self::createNote($mail);
732
733                 if (!$object_mode) {
734                         $data = ['@context' => ActivityPub::CONTEXT];
735                 } else {
736                         $data = [];
737                 }
738
739                 $data['id'] = $mail['uri'] . '#Create';
740                 $data['type'] = 'Create';
741                 $data['actor'] = $mail['author-link'];
742                 $data['published'] = DateTimeFormat::utc($mail['created'] . '+00:00', DateTimeFormat::ATOM);
743                 $data['instrument'] = self::getService();
744                 $data = array_merge($data, self::createPermissionBlockForItem($mail, true));
745
746                 if (empty($data['to']) && !empty($data['cc'])) {
747                         $data['to'] = $data['cc'];
748                 }
749
750                 if (empty($data['to']) && !empty($data['bcc'])) {
751                         $data['to'] = $data['bcc'];
752                 }
753
754                 unset($data['cc']);
755                 unset($data['bcc']);
756
757                 $object['to'] = $data['to'];
758                 $object['tag'] = [['type' => 'Mention', 'href' => $object['to'][0], 'name' => '']];
759
760                 unset($object['cc']);
761                 unset($object['bcc']);
762
763                 $data['directMessage'] = true;
764
765                 $data['object'] = $object;
766
767                 $owner = User::getOwnerDataById($mail['uid']);
768
769                 if (!$object_mode && !empty($owner)) {
770                         return LDSignature::sign($data, $owner);
771                 } else {
772                         return $data;
773                 }
774         }
775
776         /**
777          * Returns the activity type of a given item
778          *
779          * @param array $item
780          *
781          * @return string with activity type
782          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
783          * @throws \ImagickException
784          */
785         private static function getTypeOfItem($item)
786         {
787                 $reshared = false;
788
789                 // Only check for a reshare, if it is a real reshare and no quoted reshare
790                 if (strpos($item['body'], "[share") === 0) {
791                         $announce = self::getAnnounceArray($item);
792                         $reshared = !empty($announce);
793                 }
794
795                 if ($reshared) {
796                         $type = 'Announce';
797                 } elseif ($item['verb'] == Activity::POST) {
798                         if ($item['created'] == $item['edited']) {
799                                 $type = 'Create';
800                         } else {
801                                 $type = 'Update';
802                         }
803                 } elseif ($item['verb'] == Activity::LIKE) {
804                         $type = 'Like';
805                 } elseif ($item['verb'] == Activity::DISLIKE) {
806                         $type = 'Dislike';
807                 } elseif ($item['verb'] == Activity::ATTEND) {
808                         $type = 'Accept';
809                 } elseif ($item['verb'] == Activity::ATTENDNO) {
810                         $type = 'Reject';
811                 } elseif ($item['verb'] == Activity::ATTENDMAYBE) {
812                         $type = 'TentativeAccept';
813                 } elseif ($item['verb'] == Activity::FOLLOW) {
814                         $type = 'Follow';
815                 } elseif ($item['verb'] == Activity::TAG) {
816                         $type = 'Add';
817                 } else {
818                         $type = '';
819                 }
820
821                 return $type;
822         }
823
824         /**
825          * Creates the activity or fetches it from the cache
826          *
827          * @param integer $item_id
828          * @param boolean $force Force new cache entry
829          *
830          * @return array with the activity
831          * @throws \Exception
832          */
833         public static function createCachedActivityFromItem($item_id, $force = false)
834         {
835                 $cachekey = 'APDelivery:createActivity:' . $item_id;
836
837                 if (!$force) {
838                         $data = DI::cache()->get($cachekey);
839                         if (!is_null($data)) {
840                                 return $data;
841                         }
842                 }
843
844                 $data = ActivityPub\Transmitter::createActivityFromItem($item_id);
845
846                 DI::cache()->set($cachekey, $data, Duration::QUARTER_HOUR);
847                 return $data;
848         }
849
850         /**
851          * Creates an activity array for a given item id
852          *
853          * @param integer $item_id
854          * @param boolean $object_mode Is the activity item is used inside another object?
855          *
856          * @return array of activity
857          * @throws \Exception
858          */
859         public static function createActivityFromItem($item_id, $object_mode = false)
860         {
861                 $item = Item::selectFirst([], ['id' => $item_id, 'parent-network' => Protocol::NATIVE_SUPPORT]);
862
863                 if (!DBA::isResult($item)) {
864                         return false;
865                 }
866
867                 if ($item['wall'] && ($item['uri'] == $item['parent-uri'])) {
868                         $owner = User::getOwnerDataById($item['uid']);
869                         if (($owner['account-type'] == User::ACCOUNT_TYPE_COMMUNITY) && ($item['author-link'] != $owner['url'])) {
870                                 $type = 'Announce';
871
872                                 // Disguise forum posts as reshares. Will later be converted to a real announce
873                                 $item['body'] = share_header($item['author-name'], $item['author-link'], $item['author-avatar'],
874                                         $item['guid'], $item['created'], $item['plink']) . $item['body'] . '[/share]';
875                         }
876                 }
877
878                 if (empty($type)) {
879                         $condition = ['item-uri' => $item['uri'], 'protocol' => Conversation::PARCEL_ACTIVITYPUB];
880                         $conversation = DBA::selectFirst('conversation', ['source'], $condition);
881                         if (DBA::isResult($conversation)) {
882                                 $data = json_decode($conversation['source'], true);
883                                 if (!empty($data)) {
884                                         return $data;
885                                 }
886                         }
887
888                         $type = self::getTypeOfItem($item);
889                 }
890
891                 if (!$object_mode) {
892                         $data = ['@context' => ActivityPub::CONTEXT];
893
894                         if ($item['deleted'] && ($item['gravity'] == GRAVITY_ACTIVITY)) {
895                                 $type = 'Undo';
896                         } elseif ($item['deleted']) {
897                                 $type = 'Delete';
898                         }
899                 } else {
900                         $data = [];
901                 }
902
903                 $data['id'] = $item['uri'] . '#' . $type;
904                 $data['type'] = $type;
905
906                 if (Item::isForumPost($item) && ($type != 'Announce')) {
907                         $data['actor'] = $item['author-link'];
908                 } else {
909                         $data['actor'] = $item['owner-link'];
910                 }
911
912                 $data['published'] = DateTimeFormat::utc($item['created'] . '+00:00', DateTimeFormat::ATOM);
913
914                 $data['instrument'] = self::getService();
915
916                 $data = array_merge($data, self::createPermissionBlockForItem($item, false));
917
918                 if (in_array($data['type'], ['Create', 'Update', 'Delete'])) {
919                         $data['object'] = self::createNote($item);
920                 } elseif ($data['type'] == 'Add') {
921                         $data = self::createAddTag($item, $data);
922                 } elseif ($data['type'] == 'Announce') {
923                         $data = self::createAnnounce($item, $data);
924                 } elseif ($data['type'] == 'Follow') {
925                         $data['object'] = $item['parent-uri'];
926                 } elseif ($data['type'] == 'Undo') {
927                         $data['object'] = self::createActivityFromItem($item_id, true);
928                 } else {
929                         $data['diaspora:guid'] = $item['guid'];
930                         if (!empty($item['signed_text'])) {
931                                 $data['diaspora:like'] = $item['signed_text'];
932                         }
933                         $data['object'] = $item['thr-parent'];
934                 }
935
936                 if (!empty($item['contact-uid'])) {
937                         $uid = $item['contact-uid'];
938                 } else {
939                         $uid = $item['uid'];
940                 }
941
942                 $owner = User::getOwnerDataById($uid);
943
944                 if (!$object_mode && !empty($owner)) {
945                         return LDSignature::sign($data, $owner);
946                 } else {
947                         return $data;
948                 }
949
950                 /// @todo Create "conversation" entry
951         }
952
953         /**
954          * Creates a location entry for a given item array
955          *
956          * @param array $item
957          *
958          * @return array with location array
959          */
960         private static function createLocation($item)
961         {
962                 $location = ['type' => 'Place'];
963
964                 if (!empty($item['location'])) {
965                         $location['name'] = $item['location'];
966                 }
967
968                 $coord = [];
969
970                 if (empty($item['coord'])) {
971                         $coord = Map::getCoordinates($item['location']);
972                 } else {
973                         $coords = explode(' ', $item['coord']);
974                         if (count($coords) == 2) {
975                                 $coord = ['lat' => $coords[0], 'lon' => $coords[1]];
976                         }
977                 }
978
979                 if (!empty($coord['lat']) && !empty($coord['lon'])) {
980                         $location['latitude'] = $coord['lat'];
981                         $location['longitude'] = $coord['lon'];
982                 }
983
984                 return $location;
985         }
986
987         /**
988          * Returns a tag array for a given item array
989          *
990          * @param array $item
991          *
992          * @return array of tags
993          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
994          */
995         private static function createTagList($item)
996         {
997                 $tags = [];
998
999                 $terms = Term::tagArrayFromItemId($item['id'], [Term::HASHTAG, Term::MENTION, Term::IMPLICIT_MENTION]);
1000                 foreach ($terms as $term) {
1001                         if ($term['type'] == Term::HASHTAG) {
1002                                 $url = DI::baseUrl() . '/search?tag=' . urlencode($term['term']);
1003                                 $tags[] = ['type' => 'Hashtag', 'href' => $url, 'name' => '#' . $term['term']];
1004                         } elseif ($term['type'] == Term::MENTION || $term['type'] == Term::IMPLICIT_MENTION) {
1005                                 $contact = Contact::getDetailsByURL($term['url']);
1006                                 if (!empty($contact['addr'])) {
1007                                         $mention = '@' . $contact['addr'];
1008                                 } else {
1009                                         $mention = '@' . $term['url'];
1010                                 }
1011
1012                                 $tags[] = ['type' => 'Mention', 'href' => $term['url'], 'name' => $mention];
1013                         }
1014                 }
1015
1016                 $announce = self::getAnnounceArray($item);
1017                 // Mention the original author upon commented reshares
1018                 if (!empty($announce['comment'])) {
1019                         $tags[] = ['type' => 'Mention', 'href' => $announce['actor']['url'], 'name' => '@' . $announce['actor']['addr']];
1020                 }
1021
1022                 return $tags;
1023         }
1024
1025         /**
1026          * Adds attachment data to the JSON document
1027          *
1028          * @param array  $item Data of the item that is to be posted
1029          * @param string $type Object type
1030          *
1031          * @return array with attachment data
1032          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1033          */
1034         private static function createAttachmentList($item, $type)
1035         {
1036                 $attachments = [];
1037
1038                 // Currently deactivated, since it creates side effects on Mastodon and Pleroma.
1039                 // It will be reactivated, once this cleared.
1040                 /*
1041                 $attach_data = BBCode::getAttachmentData($item['body']);
1042                 if (!empty($attach_data['url'])) {
1043                         $attachment = ['type' => 'Page',
1044                                 'mediaType' => 'text/html',
1045                                 'url' => $attach_data['url']];
1046
1047                         if (!empty($attach_data['title'])) {
1048                                 $attachment['name'] = $attach_data['title'];
1049                         }
1050
1051                         if (!empty($attach_data['description'])) {
1052                                 $attachment['summary'] = $attach_data['description'];
1053                         }
1054
1055                         if (!empty($attach_data['image'])) {
1056                                 $imgdata = Images::getInfoFromURLCached($attach_data['image']);
1057                                 if ($imgdata) {
1058                                         $attachment['icon'] = ['type' => 'Image',
1059                                                 'mediaType' => $imgdata['mime'],
1060                                                 'width' => $imgdata[0],
1061                                                 'height' => $imgdata[1],
1062                                                 'url' => $attach_data['image']];
1063                                 }
1064                         }
1065
1066                         $attachments[] = $attachment;
1067                 }
1068                 */
1069                 $arr = explode('[/attach],', $item['attach']);
1070                 if (count($arr)) {
1071                         foreach ($arr as $r) {
1072                                 $matches = false;
1073                                 $cnt = preg_match('|\[attach\]href=\"(.*?)\" length=\"(.*?)\" type=\"(.*?)\" title=\"(.*?)\"|', $r, $matches);
1074                                 if ($cnt) {
1075                                         $attributes = ['type' => 'Document',
1076                                                         'mediaType' => $matches[3],
1077                                                         'url' => $matches[1],
1078                                                         'name' => null];
1079
1080                                         if (trim($matches[4]) != '') {
1081                                                 $attributes['name'] = trim($matches[4]);
1082                                         }
1083
1084                                         $attachments[] = $attributes;
1085                                 }
1086                         }
1087                 }
1088
1089                 if ($type != 'Note') {
1090                         return $attachments;
1091                 }
1092
1093                 // Simplify image codes
1094                 $body = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $item['body']);
1095
1096                 // Grab all pictures without alternative descriptions and create attachments out of them
1097                 if (preg_match_all("/\[img\]([^\[\]]*)\[\/img\]/Usi", $body, $pictures)) {
1098                         foreach ($pictures[1] as $picture) {
1099                                 $imgdata = Images::getInfoFromURLCached($picture);
1100                                 if ($imgdata) {
1101                                         $attachments[] = ['type' => 'Document',
1102                                                 'mediaType' => $imgdata['mime'],
1103                                                 'url' => $picture,
1104                                                 'name' => null];
1105                                 }
1106                         }
1107                 }
1108
1109                 // Grab all pictures with alternative description and create attachments out of them
1110                 if (preg_match_all("/\[img=([^\[\]]*)\]([^\[\]]*)\[\/img\]/Usi", $body, $pictures, PREG_SET_ORDER)) {
1111                         foreach ($pictures as $picture) {
1112                                 $imgdata = Images::getInfoFromURLCached($picture[1]);
1113                                 if ($imgdata) {
1114                                         $attachments[] = ['type' => 'Document',
1115                                                 'mediaType' => $imgdata['mime'],
1116                                                 'url' => $picture[1],
1117                                                 'name' => $picture[2]];
1118                                 }
1119                         }
1120                 }
1121
1122                 return $attachments;
1123         }
1124
1125         /**
1126          * Callback function to replace a Friendica style mention in a mention that is used on AP
1127          *
1128          * @param array $match Matching values for the callback
1129          * @return string Replaced mention
1130          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1131          */
1132         private static function mentionCallback($match)
1133         {
1134                 if (empty($match[1])) {
1135                         return '';
1136                 }
1137
1138                 $data = Contact::getDetailsByURL($match[1]);
1139                 if (empty($data['nick'])) {
1140                         return $match[0];
1141                 }
1142
1143                 return '@[url=' . $data['url'] . ']' . $data['nick'] . '[/url]';
1144         }
1145
1146         /**
1147          * Remove image elements since they are added as attachment
1148          *
1149          * @param string $body
1150          *
1151          * @return string with removed images
1152          */
1153         private static function removePictures($body)
1154         {
1155                 // Simplify image codes
1156                 $body = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $body);
1157                 $body = preg_replace("/\[img\=(.*?)\](.*?)\[\/img\]/ism", '[img]$1[/img]', $body);
1158
1159                 // Now remove local links
1160                 $body = preg_replace_callback(
1161                         '/\[url=([^\[\]]*)\]\[img\](.*)\[\/img\]\[\/url\]/Usi',
1162                         function ($match) {
1163                                 // We remove the link when it is a link to a local photo page
1164                                 if (Photo::isLocalPage($match[1])) {
1165                                         return '';
1166                                 }
1167                                 // otherwise we just return the link
1168                                 return '[url]' . $match[1] . '[/url]';
1169                         },
1170                         $body
1171                 );
1172
1173                 // Remove all pictures
1174                 $body = preg_replace("/\[img\]([^\[\]]*)\[\/img\]/Usi", '', $body);
1175
1176                 return $body;
1177         }
1178
1179         /**
1180          * Fetches the "context" value for a givem item array from the "conversation" table
1181          *
1182          * @param array $item
1183          *
1184          * @return string with context url
1185          * @throws \Exception
1186          */
1187         private static function fetchContextURLForItem($item)
1188         {
1189                 $conversation = DBA::selectFirst('conversation', ['conversation-href', 'conversation-uri'], ['item-uri' => $item['parent-uri']]);
1190                 if (DBA::isResult($conversation) && !empty($conversation['conversation-href'])) {
1191                         $context_uri = $conversation['conversation-href'];
1192                 } elseif (DBA::isResult($conversation) && !empty($conversation['conversation-uri'])) {
1193                         $context_uri = $conversation['conversation-uri'];
1194                 } else {
1195                         $context_uri = $item['parent-uri'] . '#context';
1196                 }
1197                 return $context_uri;
1198         }
1199
1200         /**
1201          * Returns if the post contains sensitive content ("nsfw")
1202          *
1203          * @param integer $item_id
1204          *
1205          * @return boolean
1206          * @throws \Exception
1207          */
1208         private static function isSensitive($item_id)
1209         {
1210                 $condition = ['otype' => TERM_OBJ_POST, 'oid' => $item_id, 'type' => TERM_HASHTAG, 'term' => 'nsfw'];
1211                 return DBA::exists('term', $condition);
1212         }
1213
1214         /**
1215          * Creates event data
1216          *
1217          * @param array $item
1218          *
1219          * @return array with the event data
1220          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1221          */
1222         public static function createEvent($item)
1223         {
1224                 $event = [];
1225                 $event['name'] = $item['event-summary'];
1226                 $event['content'] = BBCode::convert($item['event-desc'], false, 9);
1227                 $event['startTime'] = DateTimeFormat::utc($item['event-start'] . '+00:00', DateTimeFormat::ATOM);
1228
1229                 if (!$item['event-nofinish']) {
1230                         $event['endTime'] = DateTimeFormat::utc($item['event-finish'] . '+00:00', DateTimeFormat::ATOM);
1231                 }
1232
1233                 if (!empty($item['event-location'])) {
1234                         $item['location'] = $item['event-location'];
1235                         $event['location'] = self::createLocation($item);
1236                 }
1237
1238                 return $event;
1239         }
1240
1241         /**
1242          * Creates a note/article object array
1243          *
1244          * @param array $item
1245          *
1246          * @return array with the object data
1247          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1248          * @throws \ImagickException
1249          */
1250         public static function createNote($item)
1251         {
1252                 if (empty($item)) {
1253                         return [];
1254                 }
1255
1256                 if ($item['event-type'] == 'event') {
1257                         $type = 'Event';
1258                 } elseif (!empty($item['title'])) {
1259                         $type = 'Article';
1260                 } else {
1261                         $type = 'Note';
1262                 }
1263
1264                 if ($item['deleted']) {
1265                         $type = 'Tombstone';
1266                 }
1267
1268                 $data = [];
1269                 $data['id'] = $item['uri'];
1270                 $data['type'] = $type;
1271
1272                 if ($item['deleted']) {
1273                         return $data;
1274                 }
1275
1276                 $data['summary'] = BBCode::toPlaintext(BBCode::getAbstract($item['body'], Protocol::ACTIVITYPUB));
1277
1278                 if ($item['uri'] != $item['thr-parent']) {
1279                         $data['inReplyTo'] = $item['thr-parent'];
1280                 } else {
1281                         $data['inReplyTo'] = null;
1282                 }
1283
1284                 $data['diaspora:guid'] = $item['guid'];
1285                 $data['published'] = DateTimeFormat::utc($item['created'] . '+00:00', DateTimeFormat::ATOM);
1286
1287                 if ($item['created'] != $item['edited']) {
1288                         $data['updated'] = DateTimeFormat::utc($item['edited'] . '+00:00', DateTimeFormat::ATOM);
1289                 }
1290
1291                 $data['url'] = $item['plink'];
1292                 $data['attributedTo'] = $item['author-link'];
1293                 $data['sensitive'] = self::isSensitive($item['id']);
1294                 $data['context'] = self::fetchContextURLForItem($item);
1295
1296                 if (!empty($item['title'])) {
1297                         $data['name'] = BBCode::toPlaintext($item['title'], false);
1298                 }
1299
1300                 $permission_block = self::createPermissionBlockForItem($item, false);
1301
1302                 $body = $item['body'];
1303
1304                 if (empty($item['uid']) || !Feature::isEnabled($item['uid'], 'explicit_mentions')) {
1305                         $body = self::prependMentions($body, $permission_block);
1306                 }
1307
1308                 if ($type == 'Note') {
1309                         $body = self::removePictures($body);
1310                 } elseif (($type == 'Article') && empty($data['summary'])) {
1311                         $data['summary'] = BBCode::toPlaintext(Plaintext::shorten(self::removePictures($body), 1000));
1312                 }
1313
1314                 if ($type == 'Event') {
1315                         $data = array_merge($data, self::createEvent($item));
1316                 } else {
1317                         $regexp = "/[@!]\[url\=([^\[\]]*)\].*?\[\/url\]/ism";
1318                         $body = preg_replace_callback($regexp, ['self', 'mentionCallback'], $body);
1319
1320                         $data['content'] = BBCode::convert($body, false, 9);
1321                 }
1322
1323                 // The regular "content" field does contain a minimized HTML. This is done since systems like
1324                 // Mastodon has got problems with - for example - embedded pictures.
1325                 // The contentMap does contain the unmodified HTML.
1326                 $language = self::getLanguage($item);
1327                 if (!empty($language)) {
1328                         $regexp = "/[@!]\[url\=([^\[\]]*)\].*?\[\/url\]/ism";
1329                         $richbody = preg_replace_callback($regexp, ['self', 'mentionCallback'], $item['body']);
1330                         $richbody = BBCode::removeAttachment($richbody);
1331
1332                         $data['contentMap'][$language] = BBCode::convert($richbody, false);
1333                 }
1334
1335                 $data['source'] = ['content' => $item['body'], 'mediaType' => "text/bbcode"];
1336
1337                 if (!empty($item['signed_text']) && ($item['uri'] != $item['thr-parent'])) {
1338                         $data['diaspora:comment'] = $item['signed_text'];
1339                 }
1340
1341                 $data['attachment'] = self::createAttachmentList($item, $type);
1342                 $data['tag'] = self::createTagList($item);
1343
1344                 if (empty($data['location']) && (!empty($item['coord']) || !empty($item['location']))) {
1345                         $data['location'] = self::createLocation($item);
1346                 }
1347
1348                 if (!empty($item['app'])) {
1349                         $data['generator'] = ['type' => 'Application', 'name' => $item['app']];
1350                 }
1351
1352                 $data = array_merge($data, $permission_block);
1353
1354                 return $data;
1355         }
1356
1357         /**
1358          * Fetches the language from the post, the user or the system.
1359          *
1360          * @param array $item
1361          *
1362          * @return string language string
1363          */
1364         private static function getLanguage(array $item)
1365         {
1366                 // Try to fetch the language from the post itself
1367                 if (!empty($item['language'])) {
1368                         $languages = array_keys(json_decode($item['language'], true));
1369                         if (!empty($languages[0])) {
1370                                 return $languages[0];
1371                         }
1372                 }
1373
1374                 // Otherwise use the user's language
1375                 if (!empty($item['uid'])) {
1376                         $user = DBA::selectFirst('user', ['language'], ['uid' => $item['uid']]);
1377                         if (!empty($user['language'])) {
1378                                 return $user['language'];
1379                         }
1380                 }
1381
1382                 // And finally just use the system language
1383                 return DI::config()->get('system', 'language');
1384         }
1385
1386         /**
1387          * Creates an an "add tag" entry
1388          *
1389          * @param array $item
1390          * @param array $data activity data
1391          *
1392          * @return array with activity data for adding tags
1393          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1394          * @throws \ImagickException
1395          */
1396         private static function createAddTag($item, $data)
1397         {
1398                 $object = XML::parseString($item['object'], false);
1399                 $target = XML::parseString($item["target"], false);
1400
1401                 $data['diaspora:guid'] = $item['guid'];
1402                 $data['actor'] = $item['author-link'];
1403                 $data['target'] = (string)$target->id;
1404                 $data['summary'] = BBCode::toPlaintext($item['body']);
1405                 $data['object'] = ['id' => (string)$object->id, 'type' => 'tag', 'name' => (string)$object->title, 'content' => (string)$object->content];
1406
1407                 return $data;
1408         }
1409
1410         /**
1411          * Creates an announce object entry
1412          *
1413          * @param array $item
1414          * @param array $data activity data
1415          *
1416          * @return array with activity data
1417          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1418          * @throws \ImagickException
1419          */
1420         private static function createAnnounce($item, $data)
1421         {
1422                 $orig_body = $item['body'];
1423                 $announce = self::getAnnounceArray($item);
1424                 if (empty($announce)) {
1425                         $data['type'] = 'Create';
1426                         $data['object'] = self::createNote($item);
1427                         return $data;
1428                 }
1429
1430                 if (empty($announce['comment'])) {
1431                         // Pure announce, without a quote
1432                         $data['type'] = 'Announce';
1433                         $data['object'] = $announce['object']['uri'];
1434                         return $data;
1435                 }
1436
1437                 // Quote
1438                 $data['type'] = 'Create';
1439                 $item['body'] = $announce['comment'] . "\n" . $announce['object']['plink'];
1440                 $data['object'] = self::createNote($item);
1441
1442                 /// @todo Finally descide how to implement this in AP. This is a possible way:
1443                 $data['object']['attachment'][] = self::createNote($announce['object']);
1444
1445                 $data['object']['source']['content'] = $orig_body;
1446                 return $data;
1447         }
1448
1449         /**
1450          * Return announce related data if the item is an annunce
1451          *
1452          * @param array $item
1453          *
1454          * @return array
1455          */
1456         public static function getAnnounceArray($item)
1457         {
1458                 $reshared = Item::getShareArray($item);
1459                 if (empty($reshared['guid'])) {
1460                         return [];
1461                 }
1462
1463                 $reshared_item = Item::selectFirst([], ['guid' => $reshared['guid']]);
1464                 if (!DBA::isResult($reshared_item)) {
1465                         return [];
1466                 }
1467
1468                 if (!in_array($reshared_item['network'], [Protocol::ACTIVITYPUB, Protocol::DFRN])) {
1469                         return [];
1470                 }
1471
1472                 $profile = APContact::getByURL($reshared_item['author-link'], false);
1473                 if (empty($profile)) {
1474                         return [];
1475                 }
1476
1477                 return ['object' => $reshared_item, 'actor' => $profile, 'comment' => $reshared['comment']];
1478         }
1479
1480         /**
1481          * Checks if the provided item array is an announce
1482          *
1483          * @param array $item
1484          *
1485          * @return boolean
1486          */
1487         public static function isAnnounce($item)
1488         {
1489                 $announce = self::getAnnounceArray($item);
1490                 if (empty($announce)) {
1491                         return false;
1492                 }
1493
1494                 return empty($announce['comment']);
1495         }
1496
1497         /**
1498          * Creates an activity id for a given contact id
1499          *
1500          * @param integer $cid Contact ID of target
1501          *
1502          * @return bool|string activity id
1503          */
1504         public static function activityIDFromContact($cid)
1505         {
1506                 $contact = DBA::selectFirst('contact', ['uid', 'id', 'created'], ['id' => $cid]);
1507                 if (!DBA::isResult($contact)) {
1508                         return false;
1509                 }
1510
1511                 $hash = hash('ripemd128', $contact['uid'].'-'.$contact['id'].'-'.$contact['created']);
1512                 $uuid = substr($hash, 0, 8). '-' . substr($hash, 8, 4) . '-' . substr($hash, 12, 4) . '-' . substr($hash, 16, 4) . '-' . substr($hash, 20, 12);
1513                 return DI::baseUrl() . '/activity/' . $uuid;
1514         }
1515
1516         /**
1517          * Transmits a contact suggestion to a given inbox
1518          *
1519          * @param integer $uid           User ID
1520          * @param string  $inbox         Target inbox
1521          * @param integer $suggestion_id Suggestion ID
1522          *
1523          * @return boolean was the transmission successful?
1524          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1525          */
1526         public static function sendContactSuggestion($uid, $inbox, $suggestion_id)
1527         {
1528                 $owner = User::getOwnerDataById($uid);
1529
1530                 $suggestion = DI::fsuggest()->getById($suggestion_id);
1531
1532                 $data = ['@context' => ActivityPub::CONTEXT,
1533                         'id' => DI::baseUrl() . '/activity/' . System::createGUID(),
1534                         'type' => 'Announce',
1535                         'actor' => $owner['url'],
1536                         'object' => $suggestion->url,
1537                         'content' => $suggestion->note,
1538                         'instrument' => self::getService(),
1539                         'to' => [ActivityPub::PUBLIC_COLLECTION],
1540                         'cc' => []];
1541
1542                 $signed = LDSignature::sign($data, $owner);
1543
1544                 Logger::log('Deliver profile deletion for user ' . $uid . ' to ' . $inbox . ' via ActivityPub', Logger::DEBUG);
1545                 return HTTPSignature::transmit($signed, $inbox, $uid);
1546         }
1547
1548         /**
1549          * Transmits a profile relocation to a given inbox
1550          *
1551          * @param integer $uid   User ID
1552          * @param string  $inbox Target inbox
1553          *
1554          * @return boolean was the transmission successful?
1555          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1556          */
1557         public static function sendProfileRelocation($uid, $inbox)
1558         {
1559                 $owner = User::getOwnerDataById($uid);
1560
1561                 $data = ['@context' => ActivityPub::CONTEXT,
1562                         'id' => DI::baseUrl() . '/activity/' . System::createGUID(),
1563                         'type' => 'dfrn:relocate',
1564                         'actor' => $owner['url'],
1565                         'object' => $owner['url'],
1566                         'published' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
1567                         'instrument' => self::getService(),
1568                         'to' => [ActivityPub::PUBLIC_COLLECTION],
1569                         'cc' => []];
1570
1571                 $signed = LDSignature::sign($data, $owner);
1572
1573                 Logger::log('Deliver profile relocation for user ' . $uid . ' to ' . $inbox . ' via ActivityPub', Logger::DEBUG);
1574                 return HTTPSignature::transmit($signed, $inbox, $uid);
1575         }
1576
1577         /**
1578          * Transmits a profile deletion to a given inbox
1579          *
1580          * @param integer $uid   User ID
1581          * @param string  $inbox Target inbox
1582          *
1583          * @return boolean was the transmission successful?
1584          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1585          */
1586         public static function sendProfileDeletion($uid, $inbox)
1587         {
1588                 $owner = User::getOwnerDataById($uid);
1589
1590                 if (empty($owner)) {
1591                         Logger::error('No owner data found, the deletion message cannot be processed.', ['user' => $uid]);
1592                         return false;
1593                 }
1594
1595                 if (empty($owner['uprvkey'])) {
1596                         Logger::error('No private key for owner found, the deletion message cannot be processed.', ['user' => $uid]);
1597                         return false;
1598                 }
1599
1600                 $data = ['@context' => ActivityPub::CONTEXT,
1601                         'id' => DI::baseUrl() . '/activity/' . System::createGUID(),
1602                         'type' => 'Delete',
1603                         'actor' => $owner['url'],
1604                         'object' => $owner['url'],
1605                         'published' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
1606                         'instrument' => self::getService(),
1607                         'to' => [ActivityPub::PUBLIC_COLLECTION],
1608                         'cc' => []];
1609
1610                 $signed = LDSignature::sign($data, $owner);
1611
1612                 Logger::log('Deliver profile deletion for user ' . $uid . ' to ' . $inbox . ' via ActivityPub', Logger::DEBUG);
1613                 return HTTPSignature::transmit($signed, $inbox, $uid);
1614         }
1615
1616         /**
1617          * Transmits a profile change to a given inbox
1618          *
1619          * @param integer $uid   User ID
1620          * @param string  $inbox Target inbox
1621          *
1622          * @return boolean was the transmission successful?
1623          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1624          * @throws \ImagickException
1625          */
1626         public static function sendProfileUpdate($uid, $inbox)
1627         {
1628                 $owner = User::getOwnerDataById($uid);
1629                 $profile = APContact::getByURL($owner['url']);
1630
1631                 $data = ['@context' => ActivityPub::CONTEXT,
1632                         'id' => DI::baseUrl() . '/activity/' . System::createGUID(),
1633                         'type' => 'Update',
1634                         'actor' => $owner['url'],
1635                         'object' => self::getProfile($uid),
1636                         'published' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
1637                         'instrument' => self::getService(),
1638                         'to' => [$profile['followers']],
1639                         'cc' => []];
1640
1641                 $signed = LDSignature::sign($data, $owner);
1642
1643                 Logger::log('Deliver profile update for user ' . $uid . ' to ' . $inbox . ' via ActivityPub', Logger::DEBUG);
1644                 return HTTPSignature::transmit($signed, $inbox, $uid);
1645         }
1646
1647         /**
1648          * Transmits a given activity to a target
1649          *
1650          * @param string  $activity Type name
1651          * @param string  $target   Target profile
1652          * @param integer $uid      User ID
1653          * @return bool
1654          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1655          * @throws \ImagickException
1656          * @throws \Exception
1657          */
1658         public static function sendActivity($activity, $target, $uid, $id = '')
1659         {
1660                 $profile = APContact::getByURL($target);
1661                 if (empty($profile['inbox'])) {
1662                         Logger::warning('No inbox found for target', ['target' => $target, 'profile' => $profile]);
1663                         return;
1664                 }
1665
1666                 $owner = User::getOwnerDataById($uid);
1667
1668                 if (empty($id)) {
1669                         $id = DI::baseUrl() . '/activity/' . System::createGUID();
1670                 }
1671
1672                 $data = ['@context' => ActivityPub::CONTEXT,
1673                         'id' => $id,
1674                         'type' => $activity,
1675                         'actor' => $owner['url'],
1676                         'object' => $profile['url'],
1677                         'instrument' => self::getService(),
1678                         'to' => [$profile['url']]];
1679
1680                 Logger::log('Sending activity ' . $activity . ' to ' . $target . ' for user ' . $uid, Logger::DEBUG);
1681
1682                 $signed = LDSignature::sign($data, $owner);
1683                 return HTTPSignature::transmit($signed, $profile['inbox'], $uid);
1684         }
1685
1686         /**
1687          * Transmits a "follow object" activity to a target
1688          * This is a preparation for sending automated "follow" requests when receiving "Announce" messages
1689          *
1690          * @param string  $object Object URL
1691          * @param string  $target Target profile
1692          * @param integer $uid    User ID
1693          * @return bool
1694          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1695          * @throws \ImagickException
1696          * @throws \Exception
1697          */
1698         public static function sendFollowObject($object, $target, $uid = 0)
1699         {
1700                 $profile = APContact::getByURL($target);
1701                 if (empty($profile['inbox'])) {
1702                         Logger::warning('No inbox found for target', ['target' => $target, 'profile' => $profile]);
1703                         return;
1704                 }
1705
1706                 if (empty($uid)) {
1707                         // Fetch the list of administrators
1708                         $admin_mail = explode(',', str_replace(' ', '', DI::config()->get('config', 'admin_email')));
1709
1710                         // We need to use some user as a sender. It doesn't care who it will send. We will use an administrator account.
1711                         $condition = ['verified' => true, 'blocked' => false, 'account_removed' => false, 'account_expired' => false, 'email' => $admin_mail];
1712                         $first_user = DBA::selectFirst('user', ['uid'], $condition);
1713                         $uid = $first_user['uid'];
1714                 }
1715
1716                 $condition = ['verb' => Activity::FOLLOW, 'uid' => 0, 'parent-uri' => $object,
1717                         'author-id' => Contact::getPublicIdByUserId($uid)];
1718                 if (Item::exists($condition)) {
1719                         Logger::log('Follow for ' . $object . ' for user ' . $uid . ' does already exist.', Logger::DEBUG);
1720                         return false;
1721                 }
1722
1723                 $owner = User::getOwnerDataById($uid);
1724
1725                 $data = ['@context' => ActivityPub::CONTEXT,
1726                         'id' => DI::baseUrl() . '/activity/' . System::createGUID(),
1727                         'type' => 'Follow',
1728                         'actor' => $owner['url'],
1729                         'object' => $object,
1730                         'instrument' => self::getService(),
1731                         'to' => [$profile['url']]];
1732
1733                 Logger::log('Sending follow ' . $object . ' to ' . $target . ' for user ' . $uid, Logger::DEBUG);
1734
1735                 $signed = LDSignature::sign($data, $owner);
1736                 return HTTPSignature::transmit($signed, $profile['inbox'], $uid);
1737         }
1738
1739         /**
1740          * Transmit a message that the contact request had been accepted
1741          *
1742          * @param string  $target Target profile
1743          * @param         $id
1744          * @param integer $uid    User ID
1745          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1746          * @throws \ImagickException
1747          */
1748         public static function sendContactAccept($target, $id, $uid)
1749         {
1750                 $profile = APContact::getByURL($target);
1751                 if (empty($profile['inbox'])) {
1752                         Logger::warning('No inbox found for target', ['target' => $target, 'profile' => $profile]);
1753                         return;
1754                 }
1755
1756                 $owner = User::getOwnerDataById($uid);
1757                 $data = ['@context' => ActivityPub::CONTEXT,
1758                         'id' => DI::baseUrl() . '/activity/' . System::createGUID(),
1759                         'type' => 'Accept',
1760                         'actor' => $owner['url'],
1761                         'object' => [
1762                                 'id' => (string)$id,
1763                                 'type' => 'Follow',
1764                                 'actor' => $profile['url'],
1765                                 'object' => $owner['url']
1766                         ],
1767                         'instrument' => self::getService(),
1768                         'to' => [$profile['url']]];
1769
1770                 Logger::debug('Sending accept to ' . $target . ' for user ' . $uid . ' with id ' . $id);
1771
1772                 $signed = LDSignature::sign($data, $owner);
1773                 HTTPSignature::transmit($signed, $profile['inbox'], $uid);
1774         }
1775
1776         /**
1777          * Reject a contact request or terminates the contact relation
1778          *
1779          * @param string  $target Target profile
1780          * @param         $id
1781          * @param integer $uid    User ID
1782          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1783          * @throws \ImagickException
1784          */
1785         public static function sendContactReject($target, $id, $uid)
1786         {
1787                 $profile = APContact::getByURL($target);
1788                 if (empty($profile['inbox'])) {
1789                         Logger::warning('No inbox found for target', ['target' => $target, 'profile' => $profile]);
1790                         return;
1791                 }
1792
1793                 $owner = User::getOwnerDataById($uid);
1794                 $data = ['@context' => ActivityPub::CONTEXT,
1795                         'id' => DI::baseUrl() . '/activity/' . System::createGUID(),
1796                         'type' => 'Reject',
1797                         'actor' => $owner['url'],
1798                         'object' => [
1799                                 'id' => (string)$id,
1800                                 'type' => 'Follow',
1801                                 'actor' => $profile['url'],
1802                                 'object' => $owner['url']
1803                         ],
1804                         'instrument' => self::getService(),
1805                         'to' => [$profile['url']]];
1806
1807                 Logger::debug('Sending reject to ' . $target . ' for user ' . $uid . ' with id ' . $id);
1808
1809                 $signed = LDSignature::sign($data, $owner);
1810                 HTTPSignature::transmit($signed, $profile['inbox'], $uid);
1811         }
1812
1813         /**
1814          * Transmits a message that we don't want to follow this contact anymore
1815          *
1816          * @param string  $target Target profile
1817          * @param integer $uid    User ID
1818          * @throws \Friendica\Network\HTTPException\InternalServerErrorException
1819          * @throws \ImagickException
1820          * @throws \Exception
1821          */
1822         public static function sendContactUndo($target, $cid, $uid)
1823         {
1824                 $profile = APContact::getByURL($target);
1825                 if (empty($profile['inbox'])) {
1826                         Logger::warning('No inbox found for target', ['target' => $target, 'profile' => $profile]);
1827                         return;
1828                 }
1829
1830                 $object_id = self::activityIDFromContact($cid);
1831                 if (empty($object_id)) {
1832                         return;
1833                 }
1834
1835                 $id = DI::baseUrl() . '/activity/' . System::createGUID();
1836
1837                 $owner = User::getOwnerDataById($uid);
1838                 $data = ['@context' => ActivityPub::CONTEXT,
1839                         'id' => $id,
1840                         'type' => 'Undo',
1841                         'actor' => $owner['url'],
1842                         'object' => ['id' => $object_id, 'type' => 'Follow',
1843                                 'actor' => $owner['url'],
1844                                 'object' => $profile['url']],
1845                         'instrument' => self::getService(),
1846                         'to' => [$profile['url']]];
1847
1848                 Logger::log('Sending undo to ' . $target . ' for user ' . $uid . ' with id ' . $id, Logger::DEBUG);
1849
1850                 $signed = LDSignature::sign($data, $owner);
1851                 HTTPSignature::transmit($signed, $profile['inbox'], $uid);
1852         }
1853
1854         private static function prependMentions($body, array $permission_block)
1855         {
1856                 if (DI::config()->get('system', 'disable_implicit_mentions')) {
1857                         return $body;
1858                 }
1859
1860                 $mentions = [];
1861
1862                 foreach ($permission_block['to'] as $profile_url) {
1863                         $profile = Contact::getDetailsByURL($profile_url);
1864                         if (!empty($profile['addr'])
1865                                 && $profile['contact-type'] != Contact::TYPE_COMMUNITY
1866                                 && !strstr($body, $profile['addr'])
1867                                 && !strstr($body, $profile_url)
1868                         ) {
1869                                 $mentions[] = '@[url=' . $profile_url . ']' . $profile['nick'] . '[/url]';
1870                         }
1871                 }
1872
1873                 $mentions[] = $body;
1874
1875                 return implode(' ', $mentions);
1876         }
1877 }