]> git.mxchange.org Git - friendica.git/blob - src/Protocol/ActivityPub/Transmitter.php
e1da961534dadc9a7b7a503c8c84cb0723742f3e
[friendica.git] / src / Protocol / ActivityPub / Transmitter.php
1 <?php
2 /**
3  * @file src/Protocol/ActivityPub/Transmitter.php
4  */
5 namespace Friendica\Protocol\ActivityPub;
6
7 use Friendica\BaseObject;
8 use Friendica\Database\DBA;
9 use Friendica\Core\System;
10 use Friendica\Util\HTTPSignature;
11 use Friendica\Core\Protocol;
12 use Friendica\Model\Conversation;
13 use Friendica\Model\Contact;
14 use Friendica\Model\APContact;
15 use Friendica\Model\Item;
16 use Friendica\Model\Term;
17 use Friendica\Model\User;
18 use Friendica\Util\DateTimeFormat;
19 use Friendica\Content\Text\BBCode;
20 use Friendica\Util\JsonLD;
21 use Friendica\Util\LDSignature;
22 use Friendica\Model\Profile;
23 use Friendica\Core\Config;
24 use Friendica\Object\Image;
25 use Friendica\Protocol\ActivityPub;
26 use Friendica\Protocol\Diaspora;
27 use Friendica\Core\Cache;
28 use Friendica\Util\Map;
29
30 require_once 'include/api.php';
31
32 /**
33  * @brief ActivityPub Transmitter Protocol class
34  *
35  * To-Do:
36  *
37  * Missing object types:
38  * - Event
39  *
40  * Complicated object types:
41  * - Undo Announce
42  */
43 class Transmitter
44 {
45         /**
46          * collects the lost of followers of the given owner
47          *
48          * @param array $owner Owner array
49          * @param integer $page Page number
50          *
51          * @return array of owners
52          */
53         public static function getFollowers($owner, $page = null)
54         {
55                 $condition = ['rel' => [Contact::FOLLOWER, Contact::FRIEND], 'network' => Protocol::NATIVE_SUPPORT, 'uid' => $owner['uid'],
56                         'self' => false, 'hidden' => false, 'archive' => false, 'pending' => false];
57                 $count = DBA::count('contact', $condition);
58
59                 $data = ['@context' => ActivityPub::CONTEXT];
60                 $data['id'] = System::baseUrl() . '/followers/' . $owner['nickname'];
61                 $data['type'] = 'OrderedCollection';
62                 $data['totalItems'] = $count;
63
64                 // When we hide our friends we will only show the pure number but don't allow more.
65                 $profile = Profile::getByUID($owner['uid']);
66                 if (!empty($profile['hide-friends'])) {
67                         return $data;
68                 }
69
70                 if (empty($page)) {
71                         $data['first'] = System::baseUrl() . '/followers/' . $owner['nickname'] . '?page=1';
72                 } else {
73                         $list = [];
74
75                         $contacts = DBA::select('contact', ['url'], $condition, ['limit' => [($page - 1) * 100, 100]]);
76                         while ($contact = DBA::fetch($contacts)) {
77                                 $list[] = $contact['url'];
78                         }
79
80                         if (!empty($list)) {
81                                 $data['next'] = System::baseUrl() . '/followers/' . $owner['nickname'] . '?page=' . ($page + 1);
82                         }
83
84                         $data['partOf'] = System::baseUrl() . '/followers/' . $owner['nickname'];
85
86                         $data['orderedItems'] = $list;
87                 }
88
89                 return $data;
90         }
91
92         /**
93          * Create list of following contacts
94          *
95          * @param array $owner Owner array
96          * @param integer $page Page numbe
97          *
98          * @return array of following contacts
99          */
100         public static function getFollowing($owner, $page = null)
101         {
102                 $condition = ['rel' => [Contact::SHARING, Contact::FRIEND], 'network' => Protocol::NATIVE_SUPPORT, 'uid' => $owner['uid'],
103                         'self' => false, 'hidden' => false, 'archive' => false, 'pending' => false];
104                 $count = DBA::count('contact', $condition);
105
106                 $data = ['@context' => ActivityPub::CONTEXT];
107                 $data['id'] = System::baseUrl() . '/following/' . $owner['nickname'];
108                 $data['type'] = 'OrderedCollection';
109                 $data['totalItems'] = $count;
110
111                 // When we hide our friends we will only show the pure number but don't allow more.
112                 $profile = Profile::getByUID($owner['uid']);
113                 if (!empty($profile['hide-friends'])) {
114                         return $data;
115                 }
116
117                 if (empty($page)) {
118                         $data['first'] = System::baseUrl() . '/following/' . $owner['nickname'] . '?page=1';
119                 } else {
120                         $list = [];
121
122                         $contacts = DBA::select('contact', ['url'], $condition, ['limit' => [($page - 1) * 100, 100]]);
123                         while ($contact = DBA::fetch($contacts)) {
124                                 $list[] = $contact['url'];
125                         }
126
127                         if (!empty($list)) {
128                                 $data['next'] = System::baseUrl() . '/following/' . $owner['nickname'] . '?page=' . ($page + 1);
129                         }
130
131                         $data['partOf'] = System::baseUrl() . '/following/' . $owner['nickname'];
132
133                         $data['orderedItems'] = $list;
134                 }
135
136                 return $data;
137         }
138
139         /**
140          * Public posts for the given owner
141          *
142          * @param array $owner Owner array
143          * @param integer $page Page numbe
144          *
145          * @return array of posts
146          */
147         public static function getOutbox($owner, $page = null)
148         {
149                 $public_contact = Contact::getIdForURL($owner['url'], 0, true);
150
151                 $condition = ['uid' => 0, 'contact-id' => $public_contact, 'author-id' => $public_contact,
152                         'private' => false, 'gravity' => [GRAVITY_PARENT, GRAVITY_COMMENT],
153                         'deleted' => false, 'visible' => true];
154                 $count = DBA::count('item', $condition);
155
156                 $data = ['@context' => ActivityPub::CONTEXT];
157                 $data['id'] = System::baseUrl() . '/outbox/' . $owner['nickname'];
158                 $data['type'] = 'OrderedCollection';
159                 $data['totalItems'] = $count;
160
161                 if (empty($page)) {
162                         $data['first'] = System::baseUrl() . '/outbox/' . $owner['nickname'] . '?page=1';
163                 } else {
164                         $list = [];
165
166                         $condition['parent-network'] = Protocol::NATIVE_SUPPORT;
167
168                         $items = Item::select(['id'], $condition, ['limit' => [($page - 1) * 20, 20], 'order' => ['created' => true]]);
169                         while ($item = Item::fetch($items)) {
170                                 $object = self::createObjectFromItemID($item['id']);
171                                 unset($object['@context']);
172                                 $list[] = $object;
173                         }
174
175                         if (!empty($list)) {
176                                 $data['next'] = System::baseUrl() . '/outbox/' . $owner['nickname'] . '?page=' . ($page + 1);
177                         }
178
179                         $data['partOf'] = System::baseUrl() . '/outbox/' . $owner['nickname'];
180
181                         $data['orderedItems'] = $list;
182                 }
183
184                 return $data;
185         }
186
187         /**
188          * Return the ActivityPub profile of the given user
189          *
190          * @param integer $uid User ID
191          * @return array with profile data
192          */
193         public static function getProfile($uid)
194         {
195                 $condition = ['uid' => $uid, 'blocked' => false, 'account_expired' => false,
196                         'account_removed' => false, 'verified' => true];
197                 $fields = ['guid', 'nickname', 'pubkey', 'account-type', 'page-flags'];
198                 $user = DBA::selectFirst('user', $fields, $condition);
199                 if (!DBA::isResult($user)) {
200                         return [];
201                 }
202
203                 $fields = ['locality', 'region', 'country-name'];
204                 $profile = DBA::selectFirst('profile', $fields, ['uid' => $uid, 'is-default' => true]);
205                 if (!DBA::isResult($profile)) {
206                         return [];
207                 }
208
209                 $fields = ['name', 'url', 'location', 'about', 'avatar', 'photo'];
210                 $contact = DBA::selectFirst('contact', $fields, ['uid' => $uid, 'self' => true]);
211                 if (!DBA::isResult($contact)) {
212                         return [];
213                 }
214
215                 // On old installations and never changed contacts this might not be filled
216                 if (empty($contact['avatar'])) {
217                         $contact['avatar'] = $contact['photo'];
218                 }
219
220                 $data = ['@context' => ActivityPub::CONTEXT];
221                 $data['id'] = $contact['url'];
222                 $data['diaspora:guid'] = $user['guid'];
223                 $data['type'] = ActivityPub::ACCOUNT_TYPES[$user['account-type']];
224                 $data['following'] = System::baseUrl() . '/following/' . $user['nickname'];
225                 $data['followers'] = System::baseUrl() . '/followers/' . $user['nickname'];
226                 $data['inbox'] = System::baseUrl() . '/inbox/' . $user['nickname'];
227                 $data['outbox'] = System::baseUrl() . '/outbox/' . $user['nickname'];
228                 $data['preferredUsername'] = $user['nickname'];
229                 $data['name'] = $contact['name'];
230                 $data['vcard:hasAddress'] = ['@type' => 'vcard:Home', 'vcard:country-name' => $profile['country-name'],
231                         'vcard:region' => $profile['region'], 'vcard:locality' => $profile['locality']];
232                 $data['summary'] = $contact['about'];
233                 $data['url'] = $contact['url'];
234                 $data['manuallyApprovesFollowers'] = in_array($user['page-flags'], [Contact::PAGE_NORMAL, Contact::PAGE_PRVGROUP]);
235                 $data['publicKey'] = ['id' => $contact['url'] . '#main-key',
236                         'owner' => $contact['url'],
237                         'publicKeyPem' => $user['pubkey']];
238                 $data['endpoints'] = ['sharedInbox' => System::baseUrl() . '/inbox'];
239                 $data['icon'] = ['type' => 'Image',
240                         'url' => $contact['avatar']];
241
242                 // tags: https://kitty.town/@inmysocks/100656097926961126.json
243                 return $data;
244         }
245
246         /**
247          * Returns an array with permissions of a given item array
248          *
249          * @param array $item
250          *
251          * @return array with permissions
252          */
253         private static function fetchPermissionBlockFromConversation($item)
254         {
255                 if (empty($item['thr-parent'])) {
256                         return [];
257                 }
258
259                 $condition = ['item-uri' => $item['thr-parent'], 'protocol' => Conversation::PARCEL_ACTIVITYPUB];
260                 $conversation = DBA::selectFirst('conversation', ['source'], $condition);
261                 if (!DBA::isResult($conversation)) {
262                         return [];
263                 }
264
265                 $activity = json_decode($conversation['source'], true);
266
267                 $actor = JsonLD::fetchElement($activity, 'actor', 'id');
268                 $profile = APContact::getByURL($actor);
269
270                 $item_profile = APContact::getByURL($item['author-link']);
271                 $exclude[] = $item['author-link'];
272
273                 if ($item['gravity'] == GRAVITY_PARENT) {
274                         $exclude[] = $item['owner-link'];
275                 }
276
277                 $permissions['to'][] = $actor;
278
279                 foreach (['to', 'cc', 'bto', 'bcc'] as $element) {
280                         if (empty($activity[$element])) {
281                                 continue;
282                         }
283                         if (is_string($activity[$element])) {
284                                 $activity[$element] = [$activity[$element]];
285                         }
286
287                         foreach ($activity[$element] as $receiver) {
288                                 if ($receiver == $profile['followers'] && !empty($item_profile['followers'])) {
289                                         $permissions[$element][] = $item_profile['followers'];
290                                 }
291                                 if (!in_array($receiver, $exclude)) {
292                                         $permissions[$element][] = $receiver;
293                                 }
294                         }
295                 }
296                 return $permissions;
297         }
298
299         /**
300          * Creates an array of permissions from an item thread
301          *
302          * @param array $item
303          * @param boolean $blindcopy
304          *
305          * @return array with permission data
306          */
307         private static function createPermissionBlockForItem($item, $blindcopy)
308         {
309                 // Will be activated in a later step
310                 // $networks = [Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::DIASPORA, Protocol::OSTATUS];
311
312                 // For now only send to these contacts:
313                 $networks = [Protocol::ACTIVITYPUB, Protocol::OSTATUS];
314
315                 $data = ['to' => [], 'cc' => [], 'bcc' => []];
316
317                 $data = array_merge($data, self::fetchPermissionBlockFromConversation($item));
318
319                 $actor_profile = APContact::getByURL($item['author-link']);
320
321                 $terms = Term::tagArrayFromItemId($item['id'], TERM_MENTION);
322
323                 if (!$item['private']) {
324                         $data['to'][] = ActivityPub::PUBLIC_COLLECTION;
325                         if (!empty($actor_profile['followers'])) {
326                                 $data['cc'][] = $actor_profile['followers'];
327                         }
328
329                         foreach ($terms as $term) {
330                                 $profile = APContact::getByURL($term['url'], false);
331                                 if (!empty($profile)) {
332                                         $data['to'][] = $profile['url'];
333                                 }
334                         }
335                 } else {
336                         $receiver_list = Item::enumeratePermissions($item);
337
338                         $mentioned = [];
339
340                         foreach ($terms as $term) {
341                                 $cid = Contact::getIdForURL($term['url'], $item['uid']);
342                                 if (!empty($cid) && in_array($cid, $receiver_list)) {
343                                         $contact = DBA::selectFirst('contact', ['url'], ['id' => $cid, 'network' => $networks]);
344                                         if (DBA::isResult($contact) && !empty($profile = APContact::getByURL($contact['url'], false))) {
345                                                 $data['to'][] = $profile['url'];
346                                         }
347                                 }
348                         }
349
350                         foreach ($receiver_list as $receiver) {
351                                 $contact = DBA::selectFirst('contact', ['url'], ['id' => $receiver, 'network' => $networks]);
352                                 if (DBA::isResult($contact) && !empty($profile = APContact::getByURL($contact['url'], false))) {
353                                         // BCC is currently deactivated, due to Pleroma and Mastodon not reacting like expected
354                                         // $data['bcc'][] = $profile['url'];
355                                         $data['cc'][] = $profile['url'];
356                                 }
357                         }
358                 }
359
360                 $parents = Item::select(['id', 'author-link', 'owner-link', 'gravity', 'uri'], ['parent' => $item['parent']]);
361                 while ($parent = Item::fetch($parents)) {
362                         // Don't include data from future posts
363                         if ($parent['id'] >= $item['id']) {
364                                 continue;
365                         }
366
367                         $profile = APContact::getByURL($parent['author-link'], false);
368                         if (!empty($profile)) {
369                                 if ($parent['uri'] == $item['thr-parent']) {
370                                         $data['to'][] = $profile['url'];
371                                 } else {
372                                         $data['cc'][] = $profile['url'];
373                                 }
374                         }
375
376                         if ($item['gravity'] != GRAVITY_PARENT) {
377                                 continue;
378                         }
379
380                         $profile = APContact::getByURL($parent['owner-link'], false);
381                         if (!empty($profile)) {
382                                 $data['cc'][] = $profile['url'];
383                         }
384                 }
385                 DBA::close($parents);
386
387                 $data['to'] = array_unique($data['to']);
388                 $data['cc'] = array_unique($data['cc']);
389                 $data['bcc'] = array_unique($data['bcc']);
390
391                 if (($key = array_search($item['author-link'], $data['to'])) !== false) {
392                         unset($data['to'][$key]);
393                 }
394
395                 if (($key = array_search($item['author-link'], $data['cc'])) !== false) {
396                         unset($data['cc'][$key]);
397                 }
398
399                 if (($key = array_search($item['author-link'], $data['bcc'])) !== false) {
400                         unset($data['bcc'][$key]);
401                 }
402
403                 foreach ($data['to'] as $to) {
404                         if (($key = array_search($to, $data['cc'])) !== false) {
405                                 unset($data['cc'][$key]);
406                         }
407
408                         if (($key = array_search($to, $data['bcc'])) !== false) {
409                                 unset($data['bcc'][$key]);
410                         }
411                 }
412
413                 foreach ($data['cc'] as $cc) {
414                         if (($key = array_search($cc, $data['bcc'])) !== false) {
415                                 unset($data['bcc'][$key]);
416                         }
417                 }
418
419                 $receivers = ['to' => array_values($data['to']), 'cc' => array_values($data['cc']), 'bcc' => array_values($data['bcc'])];
420
421                 if (!$blindcopy) {
422                         unset($receivers['bcc']);
423                 }
424
425                 return $receivers;
426         }
427
428         /**
429          * Fetches a list of inboxes of followers of a given user
430          *
431          * @param integer $uid User ID
432          * @param boolean $personal fetch personal inboxes
433          *
434          * @return array of follower inboxes
435          */
436         public static function fetchTargetInboxesforUser($uid, $personal = false)
437         {
438                 $inboxes = [];
439
440                 // Will be activated in a later step
441                 // $networks = [Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::DIASPORA, Protocol::OSTATUS];
442
443                 // For now only send to these contacts:
444                 $networks = [Protocol::ACTIVITYPUB, Protocol::OSTATUS];
445
446                 $condition = ['uid' => $uid, 'network' => $networks, 'archive' => false, 'pending' => false];
447
448                 if (!empty($uid)) {
449                         $condition['rel'] = [Contact::FOLLOWER, Contact::FRIEND];
450                 }
451
452                 $contacts = DBA::select('contact', ['url'], $condition);
453                 while ($contact = DBA::fetch($contacts)) {
454                         $profile = APContact::getByURL($contact['url'], false);
455                         if (!empty($profile)) {
456                                 if (empty($profile['sharedinbox']) || $personal) {
457                                         $target = $profile['inbox'];
458                                 } else {
459                                         $target = $profile['sharedinbox'];
460                                 }
461                                 $inboxes[$target] = $target;
462                         }
463                 }
464                 DBA::close($contacts);
465
466                 return $inboxes;
467         }
468
469         /**
470          * Fetches an array of inboxes for the given item and user
471          *
472          * @param array $item
473          * @param integer $uid User ID
474          * @param boolean $personal fetch personal inboxes
475          *
476          * @return array with inboxes
477          */
478         public static function fetchTargetInboxes($item, $uid, $personal = false)
479         {
480                 $permissions = self::createPermissionBlockForItem($item, true);
481                 if (empty($permissions)) {
482                         return [];
483                 }
484
485                 $inboxes = [];
486
487                 if ($item['gravity'] == GRAVITY_ACTIVITY) {
488                         $item_profile = APContact::getByURL($item['author-link'], false);
489                 } else {
490                         $item_profile = APContact::getByURL($item['owner-link'], false);
491                 }
492
493                 foreach (['to', 'cc', 'bto', 'bcc'] as $element) {
494                         if (empty($permissions[$element])) {
495                                 continue;
496                         }
497
498                         $blindcopy = in_array($element, ['bto', 'bcc']);
499
500                         foreach ($permissions[$element] as $receiver) {
501                                 if ($receiver == $item_profile['followers']) {
502                                         $inboxes = array_merge($inboxes, self::fetchTargetInboxesforUser($uid, $personal));
503                                 } else {
504                                         $profile = APContact::getByURL($receiver, false);
505                                         if (!empty($profile)) {
506                                                 if (empty($profile['sharedinbox']) || $personal || $blindcopy) {
507                                                         $target = $profile['inbox'];
508                                                 } else {
509                                                         $target = $profile['sharedinbox'];
510                                                 }
511                                                 $inboxes[$target] = $target;
512                                         }
513                                 }
514                         }
515                 }
516
517                 return $inboxes;
518         }
519
520         /**
521          * Returns the activity type of a given item
522          *
523          * @param array $item
524          *
525          * @return string with activity type
526          */
527         private static function getTypeOfItem($item)
528         {
529                 if (!empty(Diaspora::isReshare($item['body'], false))) {
530                         $type = 'Announce';
531                 } elseif ($item['verb'] == ACTIVITY_POST) {
532                         if ($item['created'] == $item['edited']) {
533                                 $type = 'Create';
534                         } else {
535                                 $type = 'Update';
536                         }
537                 } elseif ($item['verb'] == ACTIVITY_LIKE) {
538                         $type = 'Like';
539                 } elseif ($item['verb'] == ACTIVITY_DISLIKE) {
540                         $type = 'Dislike';
541                 } elseif ($item['verb'] == ACTIVITY_ATTEND) {
542                         $type = 'Accept';
543                 } elseif ($item['verb'] == ACTIVITY_ATTENDNO) {
544                         $type = 'Reject';
545                 } elseif ($item['verb'] == ACTIVITY_ATTENDMAYBE) {
546                         $type = 'TentativeAccept';
547                 } else {
548                         $type = '';
549                 }
550
551                 return $type;
552         }
553
554         /**
555          * Creates the activity or fetches it from the cache
556          *
557          * @param integer $item_id
558          *
559          * @return array with the activity
560          */
561         public static function createCachedActivityFromItem($item_id)
562         {
563                 $cachekey = 'APDelivery:createActivity:' . $item_id;
564                 $data = Cache::get($cachekey);
565                 if (!is_null($data)) {
566                         return $data;
567                 }
568
569                 $data = ActivityPub\Transmitter::createActivityFromItem($item_id);
570
571                 Cache::set($cachekey, $data, Cache::QUARTER_HOUR);
572                 return $data;
573         }
574
575         /**
576          * Creates an activity array for a given item id
577          *
578          * @param integer $item_id
579          * @param boolean $object_mode Is the activity item is used inside another object?
580          *
581          * @return array of activity
582          */
583         public static function createActivityFromItem($item_id, $object_mode = false)
584         {
585                 $item = Item::selectFirst([], ['id' => $item_id, 'parent-network' => Protocol::NATIVE_SUPPORT]);
586
587                 if (!DBA::isResult($item)) {
588                         return false;
589                 }
590
591                 $condition = ['item-uri' => $item['uri'], 'protocol' => Conversation::PARCEL_ACTIVITYPUB];
592                 $conversation = DBA::selectFirst('conversation', ['source'], $condition);
593                 if (DBA::isResult($conversation)) {
594                         $data = json_decode($conversation['source']);
595                         if (!empty($data)) {
596                                 return $data;
597                         }
598                 }
599
600                 $type = self::getTypeOfItem($item);
601
602                 if (!$object_mode) {
603                         $data = ['@context' => ActivityPub::CONTEXT];
604
605                         if ($item['deleted'] && ($item['gravity'] == GRAVITY_ACTIVITY)) {
606                                 $type = 'Undo';
607                         } elseif ($item['deleted']) {
608                                 $type = 'Delete';
609                         }
610                 } else {
611                         $data = [];
612                 }
613
614                 $data['id'] = $item['uri'] . '#' . $type;
615                 $data['type'] = $type;
616                 $data['actor'] = $item['owner-link'];
617
618                 $data['published'] = DateTimeFormat::utc($item['created'] . '+00:00', DateTimeFormat::ATOM);
619
620                 $data['instrument'] = ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()];
621
622                 $data = array_merge($data, self::createPermissionBlockForItem($item, false));
623
624                 if (in_array($data['type'], ['Create', 'Update', 'Delete'])) {
625                         $data['object'] = self::createNote($item);
626                 } elseif ($data['type'] == 'Announce') {
627                         $data['object'] = self::createAnnounce($item);
628                 } elseif ($data['type'] == 'Undo') {
629                         $data['object'] = self::createActivityFromItem($item_id, true);
630                 } else {
631                         $data['diaspora:guid'] = $item['guid'];
632                         $data['object'] = $item['thr-parent'];
633                 }
634
635                 $owner = User::getOwnerDataById($item['uid']);
636
637                 if (!$object_mode) {
638                         return LDSignature::sign($data, $owner);
639                 } else {
640                         return $data;
641                 }
642
643                 /// @todo Create "conversation" entry
644         }
645
646         /**
647          * Creates an object array for a given item id
648          *
649          * @param integer $item_id
650          *
651          * @return array with the object data
652          */
653         public static function createObjectFromItemID($item_id)
654         {
655                 $item = Item::selectFirst([], ['id' => $item_id, 'parent-network' => Protocol::NATIVE_SUPPORT]);
656
657                 if (!DBA::isResult($item)) {
658                         return false;
659                 }
660
661                 $data = ['@context' => ActivityPub::CONTEXT];
662                 $data = array_merge($data, self::createNote($item));
663
664                 return $data;
665         }
666
667         /**
668          * Creates a location entry for a given item array
669          *
670          * @param array $item
671          *
672          * @return array with location array
673          */
674         private static function createLocation($item)
675         {
676                 $location = ['type' => 'Place'];
677
678                 if (!empty($item['location'])) {
679                         $location['name'] = $item['location'];
680                 }
681
682                 $coord = [];
683
684                 if (empty($item['coord'])) {
685                         $coord = Map::getCoordinates($item['location']);
686                 } else {
687                         $coords = explode(' ', $item['coord']);
688                         if (count($coords) == 2) {
689                                 $coord = ['lat' => $coords[0], 'lon' => $coords[1]];
690                         }
691                 }
692
693                 if (!empty($coord['lat']) && !empty($coord['lon'])) {
694                         $location['latitude'] = $coord['lat'];
695                         $location['longitude'] = $coord['lon'];
696                 }
697
698                 return $location;
699         }
700
701         /**
702          * Returns a tag array for a given item array
703          *
704          * @param array $item
705          *
706          * @return array of tags
707          */
708         private static function createTagList($item)
709         {
710                 $tags = [];
711
712                 $terms = Term::tagArrayFromItemId($item['id']);
713                 foreach ($terms as $term) {
714                         if ($term['type'] == TERM_HASHTAG) {
715                                 $tags[] = ['type' => 'Hashtag', 'href' => $term['url'], 'name' => '#' . $term['term']];
716                         } elseif ($term['type'] == TERM_MENTION) {
717                                 $contact = Contact::getDetailsByURL($term['url']);
718                                 if (!empty($contact['addr'])) {
719                                         $mention = '@' . $contact['addr'];
720                                 } else {
721                                         $mention = '@' . $term['url'];
722                                 }
723
724                                 $tags[] = ['type' => 'Mention', 'href' => $term['url'], 'name' => $mention];
725                         }
726                 }
727                 return $tags;
728         }
729
730         /**
731          * Adds attachment data to the JSON document
732          *
733          * @param array $item Data of the item that is to be posted
734          * @param text $type Object type
735          *
736          * @return array with attachment data
737          */
738         private static function createAttachmentList($item, $type)
739         {
740                 $attachments = [];
741
742                 $arr = explode('[/attach],', $item['attach']);
743                 if (count($arr)) {
744                         foreach ($arr as $r) {
745                                 $matches = false;
746                                 $cnt = preg_match('|\[attach\]href=\"(.*?)\" length=\"(.*?)\" type=\"(.*?)\" title=\"(.*?)\"|', $r, $matches);
747                                 if ($cnt) {
748                                         $attributes = ['type' => 'Document',
749                                                         'mediaType' => $matches[3],
750                                                         'url' => $matches[1],
751                                                         'name' => null];
752
753                                         if (trim($matches[4]) != '') {
754                                                 $attributes['name'] = trim($matches[4]);
755                                         }
756
757                                         $attachments[] = $attributes;
758                                 }
759                         }
760                 }
761
762                 if ($type != 'Note') {
763                         return $attachments;
764                 }
765
766                 // Simplify image codes
767                 $body = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $item['body']);
768
769                 // Grab all pictures and create attachments out of them
770                 if (preg_match_all("/\[img\]([^\[\]]*)\[\/img\]/Usi", $body, $pictures)) {
771                         foreach ($pictures[1] as $picture) {
772                                 $imgdata = Image::getInfoFromURL($picture);
773                                 if ($imgdata) {
774                                         $attachments[] = ['type' => 'Document',
775                                                 'mediaType' => $imgdata['mime'],
776                                                 'url' => $picture,
777                                                 'name' => null];
778                                 }
779                         }
780                 }
781
782                 return $attachments;
783         }
784
785         /**
786          * @brief Callback function to replace a Friendica style mention in a mention that is used on AP
787          *
788          * @param array $match Matching values for the callback
789          * @return string Replaced mention
790          */
791         private static function mentionCallback($match)
792         {
793                 if (empty($match[1])) {
794                         return;
795                 }
796
797                 $data = Contact::getDetailsByURL($match[1]);
798                 if (empty($data) || empty($data['nick'])) {
799                         return;
800                 }
801
802                 return '@[url=' . $data['url'] . ']' . $data['nick'] . '[/url]';
803         }
804
805         /**
806          * Remove image elements and replaces them with links to the image
807          *
808          * @param string $body
809          *
810          * @return string with replaced elements
811          */
812         private static function removePictures($body)
813         {
814                 // Simplify image codes
815                 $body = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $body);
816
817                 $body = preg_replace("/\[url=([^\[\]]*)\]\[img\](.*)\[\/img\]\[\/url\]/Usi", '[url]$1[/url]', $body);
818                 $body = preg_replace("/\[img\]([^\[\]]*)\[\/img\]/Usi", '[url]$1[/url]', $body);
819
820                 return $body;
821         }
822
823         /**
824          * Fetches the "context" value for a givem item array from the "conversation" table
825          *
826          * @param array $item
827          *
828          * @return string with context url
829          */
830         private static function fetchContextURLForItem($item)
831         {
832                 $conversation = DBA::selectFirst('conversation', ['conversation-href', 'conversation-uri'], ['item-uri' => $item['parent-uri']]);
833                 if (DBA::isResult($conversation) && !empty($conversation['conversation-href'])) {
834                         $context_uri = $conversation['conversation-href'];
835                 } elseif (DBA::isResult($conversation) && !empty($conversation['conversation-uri'])) {
836                         $context_uri = $conversation['conversation-uri'];
837                 } else {
838                         $context_uri = $item['parent-uri'] . '#context';
839                 }
840                 return $context_uri;
841         }
842
843         /**
844          * Returns if the post contains sensitive content ("nsfw")
845          *
846          * @param integer $item_id
847          *
848          * @return boolean
849          */
850         private static function isSensitive($item_id)
851         {
852                 $condition = ['otype' => TERM_OBJ_POST, 'oid' => $item_id, 'type' => TERM_HASHTAG, 'term' => 'nsfw'];
853                 return DBA::exists('term', $condition);
854         }
855
856         /**
857          * Creates a note/article object array
858          *
859          * @param array $item
860          *
861          * @return array with the object data
862          */
863         public static function createNote($item)
864         {
865                 if (!empty($item['title'])) {
866                         $type = 'Article';
867                 } else {
868                         $type = 'Note';
869                 }
870
871                 if ($item['deleted']) {
872                         $type = 'Tombstone';
873                 }
874
875                 $data = [];
876                 $data['id'] = $item['uri'];
877                 $data['type'] = $type;
878
879                 if ($item['deleted']) {
880                         return $data;
881                 }
882
883                 $data['summary'] = null; // Ignore by now
884
885                 if ($item['uri'] != $item['thr-parent']) {
886                         $data['inReplyTo'] = $item['thr-parent'];
887                 } else {
888                         $data['inReplyTo'] = null;
889                 }
890
891                 $data['diaspora:guid'] = $item['guid'];
892                 $data['published'] = DateTimeFormat::utc($item['created'] . '+00:00', DateTimeFormat::ATOM);
893
894                 if ($item['created'] != $item['edited']) {
895                         $data['updated'] = DateTimeFormat::utc($item['edited'] . '+00:00', DateTimeFormat::ATOM);
896                 }
897
898                 $data['url'] = $item['plink'];
899                 $data['attributedTo'] = $item['author-link'];
900                 $data['sensitive'] = self::isSensitive($item['id']);
901                 $data['context'] = self::fetchContextURLForItem($item);
902
903                 if (!empty($item['title'])) {
904                         $data['name'] = BBCode::toPlaintext($item['title'], false);
905                 }
906
907                 $body = $item['body'];
908
909                 if ($type == 'Note') {
910                         $body = self::removePictures($body);
911                 }
912
913                 $regexp = "/[@!]\[url\=([^\[\]]*)\].*?\[\/url\]/ism";
914                 $body = preg_replace_callback($regexp, ['self', 'mentionCallback'], $body);
915
916                 $data['content'] = BBCode::convert($body, false, 7);
917                 $data['source'] = ['content' => $item['body'], 'mediaType' => "text/bbcode"];
918
919                 if (!empty($item['signed_text']) && ($item['uri'] != $item['thr-parent'])) {
920                         $data['diaspora:comment'] = $item['signed_text'];
921                 }
922
923                 $data['attachment'] = self::createAttachmentList($item, $type);
924                 $data['tag'] = self::createTagList($item);
925
926                 if (!empty($item['coord']) || !empty($item['location'])) {
927                         $data['location'] = self::createLocation($item);
928                 }
929
930                 if (!empty($item['app'])) {
931                         $data['generator'] = ['type' => 'Application', 'name' => $item['app']];
932                 }
933
934                 $data = array_merge($data, self::createPermissionBlockForItem($item, false));
935
936                 return $data;
937         }
938
939         /**
940          * Creates an announce object entry
941          *
942          * @param array $item
943          *
944          * @return string with announced object url
945          */
946         public static function createAnnounce($item)
947         {
948                 $announce = api_share_as_retweet($item);
949                 if (empty($announce['plink'])) {
950                         return self::createNote($item);
951                 }
952
953                 return $announce['plink'];
954         }
955
956         /**
957          * Transmits a contact suggestion to a given inbox
958          *
959          * @param integer $uid User ID
960          * @param string $inbox Target inbox
961          * @param integer $suggestion_id Suggestion ID
962          *
963          * @return boolean was the transmission successful?
964          */
965         public static function sendContactSuggestion($uid, $inbox, $suggestion_id)
966         {
967                 $owner = User::getOwnerDataById($uid);
968                 $profile = APContact::getByURL($owner['url']);
969
970                 $suggestion = DBA::selectFirst('fsuggest', ['url', 'note', 'created'], ['id' => $suggestion_id]);
971
972                 $data = ['@context' => 'https://www.w3.org/ns/activitystreams',
973                         'id' => System::baseUrl() . '/activity/' . System::createGUID(),
974                         'type' => 'Announce',
975                         'actor' => $owner['url'],
976                         'object' => $suggestion['url'],
977                         'content' => $suggestion['note'],
978                         'instrument' => ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()],
979                         'to' => [ActivityPub::PUBLIC_COLLECTION],
980                         'cc' => []];
981
982                 $signed = LDSignature::sign($data, $owner);
983
984                 logger('Deliver profile deletion for user ' . $uid . ' to ' . $inbox . ' via ActivityPub', LOGGER_DEBUG);
985                 return HTTPSignature::transmit($signed, $inbox, $uid);
986         }
987
988         /**
989          * Transmits a profile deletion to a given inbox
990          *
991          * @param integer $uid User ID
992          * @param string $inbox Target inbox
993          *
994          * @return boolean was the transmission successful?
995          */
996         public static function sendProfileDeletion($uid, $inbox)
997         {
998                 $owner = User::getOwnerDataById($uid);
999                 $profile = APContact::getByURL($owner['url']);
1000
1001                 $data = ['@context' => 'https://www.w3.org/ns/activitystreams',
1002                         'id' => System::baseUrl() . '/activity/' . System::createGUID(),
1003                         'type' => 'Delete',
1004                         'actor' => $owner['url'],
1005                         'object' => $owner['url'],
1006                         'published' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
1007                         'instrument' => ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()],
1008                         'to' => [ActivityPub::PUBLIC_COLLECTION],
1009                         'cc' => []];
1010
1011                 $signed = LDSignature::sign($data, $owner);
1012
1013                 logger('Deliver profile deletion for user ' . $uid . ' to ' . $inbox . ' via ActivityPub', LOGGER_DEBUG);
1014                 return HTTPSignature::transmit($signed, $inbox, $uid);
1015         }
1016
1017         /**
1018          * Transmits a profile change to a given inbox
1019          *
1020          * @param integer $uid User ID
1021          * @param string $inbox Target inbox
1022          *
1023          * @return boolean was the transmission successful?
1024          */
1025         public static function sendProfileUpdate($uid, $inbox)
1026         {
1027                 $owner = User::getOwnerDataById($uid);
1028                 $profile = APContact::getByURL($owner['url']);
1029
1030                 $data = ['@context' => 'https://www.w3.org/ns/activitystreams',
1031                         'id' => System::baseUrl() . '/activity/' . System::createGUID(),
1032                         'type' => 'Update',
1033                         'actor' => $owner['url'],
1034                         'object' => self::getProfile($uid),
1035                         'published' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
1036                         'instrument' => ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()],
1037                         'to' => [$profile['followers']],
1038                         'cc' => []];
1039
1040                 $signed = LDSignature::sign($data, $owner);
1041
1042                 logger('Deliver profile update for user ' . $uid . ' to ' . $inbox . ' via ActivityPub', LOGGER_DEBUG);
1043                 return HTTPSignature::transmit($signed, $inbox, $uid);
1044         }
1045
1046         /**
1047          * Transmits a given activity to a target
1048          *
1049          * @param array $activity
1050          * @param string $target Target profile
1051          * @param integer $uid User ID
1052          */
1053         public static function sendActivity($activity, $target, $uid)
1054         {
1055                 $profile = APContact::getByURL($target);
1056
1057                 $owner = User::getOwnerDataById($uid);
1058
1059                 $data = ['@context' => 'https://www.w3.org/ns/activitystreams',
1060                         'id' => System::baseUrl() . '/activity/' . System::createGUID(),
1061                         'type' => $activity,
1062                         'actor' => $owner['url'],
1063                         'object' => $profile['url'],
1064                         'instrument' => ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()],
1065                         'to' => $profile['url']];
1066
1067                 logger('Sending activity ' . $activity . ' to ' . $target . ' for user ' . $uid, LOGGER_DEBUG);
1068
1069                 $signed = LDSignature::sign($data, $owner);
1070                 HTTPSignature::transmit($signed, $profile['inbox'], $uid);
1071         }
1072
1073         /**
1074          * Transmit a message that the contact request had been accepted
1075          *
1076          * @param string $target Target profile
1077          * @param $id
1078          * @param integer $uid User ID
1079          */
1080         public static function sendContactAccept($target, $id, $uid)
1081         {
1082                 $profile = APContact::getByURL($target);
1083
1084                 $owner = User::getOwnerDataById($uid);
1085                 $data = ['@context' => 'https://www.w3.org/ns/activitystreams',
1086                         'id' => System::baseUrl() . '/activity/' . System::createGUID(),
1087                         'type' => 'Accept',
1088                         'actor' => $owner['url'],
1089                         'object' => ['id' => $id, 'type' => 'Follow',
1090                                 'actor' => $profile['url'],
1091                                 'object' => $owner['url']],
1092                         'instrument' => ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()],
1093                         'to' => $profile['url']];
1094
1095                 logger('Sending accept to ' . $target . ' for user ' . $uid . ' with id ' . $id, LOGGER_DEBUG);
1096
1097                 $signed = LDSignature::sign($data, $owner);
1098                 HTTPSignature::transmit($signed, $profile['inbox'], $uid);
1099         }
1100
1101         /**
1102          * Reject a contact request or terminates the contact relation
1103          *
1104          * @param string $target Target profile
1105          * @param $id
1106          * @param integer $uid User ID
1107          */
1108         public static function sendContactReject($target, $id, $uid)
1109         {
1110                 $profile = APContact::getByURL($target);
1111
1112                 $owner = User::getOwnerDataById($uid);
1113                 $data = ['@context' => 'https://www.w3.org/ns/activitystreams',
1114                         'id' => System::baseUrl() . '/activity/' . System::createGUID(),
1115                         'type' => 'Reject',
1116                         'actor' => $owner['url'],
1117                         'object' => ['id' => $id, 'type' => 'Follow',
1118                                 'actor' => $profile['url'],
1119                                 'object' => $owner['url']],
1120                         'instrument' => ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()],
1121                         'to' => $profile['url']];
1122
1123                 logger('Sending reject to ' . $target . ' for user ' . $uid . ' with id ' . $id, LOGGER_DEBUG);
1124
1125                 $signed = LDSignature::sign($data, $owner);
1126                 HTTPSignature::transmit($signed, $profile['inbox'], $uid);
1127         }
1128
1129         /**
1130          * Transmits a message that we don't want to follow this contact anymore
1131          *
1132          * @param string $target Target profile
1133          * @param integer $uid User ID
1134          */
1135         public static function sendContactUndo($target, $uid)
1136         {
1137                 $profile = APContact::getByURL($target);
1138
1139                 $id = System::baseUrl() . '/activity/' . System::createGUID();
1140
1141                 $owner = User::getOwnerDataById($uid);
1142                 $data = ['@context' => 'https://www.w3.org/ns/activitystreams',
1143                         'id' => $id,
1144                         'type' => 'Undo',
1145                         'actor' => $owner['url'],
1146                         'object' => ['id' => $id, 'type' => 'Follow',
1147                                 'actor' => $owner['url'],
1148                                 'object' => $profile['url']],
1149                         'instrument' => ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()],
1150                         'to' => $profile['url']];
1151
1152                 logger('Sending undo to ' . $target . ' for user ' . $uid . ' with id ' . $id, LOGGER_DEBUG);
1153
1154                 $signed = LDSignature::sign($data, $owner);
1155                 HTTPSignature::transmit($signed, $profile['inbox'], $uid);
1156         }
1157 }