]> git.mxchange.org Git - friendica.git/blob - src/Protocol/ActivityPub/Transmitter.php
2a40838b911ab0109033a0cd4e55b5cbc2aaa33a
[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                                         $data['bcc'][] = $profile['url'];
354                                 }
355                         }
356                 }
357
358                 $parents = Item::select(['id', 'author-link', 'owner-link', 'gravity', 'uri'], ['parent' => $item['parent']]);
359                 while ($parent = Item::fetch($parents)) {
360                         // Don't include data from future posts
361                         if ($parent['id'] >= $item['id']) {
362                                 continue;
363                         }
364
365                         $profile = APContact::getByURL($parent['author-link'], false);
366                         if (!empty($profile)) {
367                                 if ($parent['uri'] == $item['thr-parent']) {
368                                         $data['to'][] = $profile['url'];
369                                 } else {
370                                         $data['cc'][] = $profile['url'];
371                                 }
372                         }
373
374                         if ($item['gravity'] != GRAVITY_PARENT) {
375                                 continue;
376                         }
377
378                         $profile = APContact::getByURL($parent['owner-link'], false);
379                         if (!empty($profile)) {
380                                 $data['cc'][] = $profile['url'];
381                         }
382                 }
383                 DBA::close($parents);
384
385                 $data['to'] = array_unique($data['to']);
386                 $data['cc'] = array_unique($data['cc']);
387                 $data['bcc'] = array_unique($data['bcc']);
388
389                 if (($key = array_search($item['author-link'], $data['to'])) !== false) {
390                         unset($data['to'][$key]);
391                 }
392
393                 if (($key = array_search($item['author-link'], $data['cc'])) !== false) {
394                         unset($data['cc'][$key]);
395                 }
396
397                 if (($key = array_search($item['author-link'], $data['bcc'])) !== false) {
398                         unset($data['bcc'][$key]);
399                 }
400
401                 foreach ($data['to'] as $to) {
402                         if (($key = array_search($to, $data['cc'])) !== false) {
403                                 unset($data['cc'][$key]);
404                         }
405
406                         if (($key = array_search($to, $data['bcc'])) !== false) {
407                                 unset($data['bcc'][$key]);
408                         }
409                 }
410
411                 foreach ($data['cc'] as $cc) {
412                         if (($key = array_search($cc, $data['bcc'])) !== false) {
413                                 unset($data['bcc'][$key]);
414                         }
415                 }
416
417                 $receivers = ['to' => array_values($data['to']), 'cc' => array_values($data['cc']), 'bcc' => array_values($data['bcc'])];
418
419                 if (!$blindcopy) {
420                         unset($receivers['bcc']);
421                 }
422
423                 return $receivers;
424         }
425
426         /**
427          * Fetches a list of inboxes of followers of a given user
428          *
429          * @param integer $uid User ID
430          * @param boolean $personal fetch personal inboxes
431          *
432          * @return array of follower inboxes
433          */
434         public static function fetchTargetInboxesforUser($uid, $personal = false)
435         {
436                 $inboxes = [];
437
438                 // Will be activated in a later step
439                 // $networks = [Protocol::ACTIVITYPUB, Protocol::DFRN, Protocol::DIASPORA, Protocol::OSTATUS];
440
441                 // For now only send to these contacts:
442                 $networks = [Protocol::ACTIVITYPUB, Protocol::OSTATUS];
443
444                 $condition = ['uid' => $uid, 'network' => $networks, 'archive' => false, 'pending' => false];
445
446                 if (!empty($uid)) {
447                         $condition['rel'] = [Contact::FOLLOWER, Contact::FRIEND];
448                 }
449
450                 $contacts = DBA::select('contact', ['url'], $condition);
451                 while ($contact = DBA::fetch($contacts)) {
452                         $profile = APContact::getByURL($contact['url'], false);
453                         if (!empty($profile)) {
454                                 if (empty($profile['sharedinbox']) || $personal) {
455                                         $target = $profile['inbox'];
456                                 } else {
457                                         $target = $profile['sharedinbox'];
458                                 }
459                                 $inboxes[$target] = $target;
460                         }
461                 }
462                 DBA::close($contacts);
463
464                 return $inboxes;
465         }
466
467         /**
468          * Fetches an array of inboxes for the given item and user
469          *
470          * @param array $item
471          * @param integer $uid User ID
472          * @param boolean $personal fetch personal inboxes
473          *
474          * @return array with inboxes
475          */
476         public static function fetchTargetInboxes($item, $uid, $personal = false)
477         {
478                 $permissions = self::createPermissionBlockForItem($item, true);
479                 if (empty($permissions)) {
480                         return [];
481                 }
482
483                 $inboxes = [];
484
485                 if ($item['gravity'] == GRAVITY_ACTIVITY) {
486                         $item_profile = APContact::getByURL($item['author-link'], false);
487                 } else {
488                         $item_profile = APContact::getByURL($item['owner-link'], false);
489                 }
490
491                 foreach (['to', 'cc', 'bto', 'bcc'] as $element) {
492                         if (empty($permissions[$element])) {
493                                 continue;
494                         }
495
496                         $blindcopy = in_array($element, ['bto', 'bcc']);
497
498                         foreach ($permissions[$element] as $receiver) {
499                                 if ($receiver == $item_profile['followers']) {
500                                         $inboxes = array_merge($inboxes, self::fetchTargetInboxesforUser($uid, $personal));
501                                 } else {
502                                         $profile = APContact::getByURL($receiver, false);
503                                         if (!empty($profile)) {
504                                                 if (empty($profile['sharedinbox']) || $personal || $blindcopy) {
505                                                         $target = $profile['inbox'];
506                                                 } else {
507                                                         $target = $profile['sharedinbox'];
508                                                 }
509                                                 $inboxes[$target] = $target;
510                                         }
511                                 }
512                         }
513                 }
514
515                 return $inboxes;
516         }
517
518         /**
519          * Returns the activity type of a given item
520          *
521          * @param array $item
522          *
523          * @return string with activity type
524          */
525         private static function getTypeOfItem($item)
526         {
527                 if (!empty(Diaspora::isReshare($item['body'], false))) {
528                         $type = 'Announce';
529                 } elseif ($item['verb'] == ACTIVITY_POST) {
530                         if ($item['created'] == $item['edited']) {
531                                 $type = 'Create';
532                         } else {
533                                 $type = 'Update';
534                         }
535                 } elseif ($item['verb'] == ACTIVITY_LIKE) {
536                         $type = 'Like';
537                 } elseif ($item['verb'] == ACTIVITY_DISLIKE) {
538                         $type = 'Dislike';
539                 } elseif ($item['verb'] == ACTIVITY_ATTEND) {
540                         $type = 'Accept';
541                 } elseif ($item['verb'] == ACTIVITY_ATTENDNO) {
542                         $type = 'Reject';
543                 } elseif ($item['verb'] == ACTIVITY_ATTENDMAYBE) {
544                         $type = 'TentativeAccept';
545                 } else {
546                         $type = '';
547                 }
548
549                 return $type;
550         }
551
552         /**
553          * Creates the activity or fetches it from the cache
554          *
555          * @param integer $item_id
556          *
557          * @return array with the activity
558          */
559         public static function createCachedActivityFromItem($item_id)
560         {
561                 $cachekey = 'APDelivery:createActivity:' . $item_id;
562                 $data = Cache::get($cachekey);
563                 if (!is_null($data)) {
564                         return $data;
565                 }
566
567                 $data = ActivityPub\Transmitter::createActivityFromItem($item_id);
568
569                 Cache::set($cachekey, $data, Cache::QUARTER_HOUR);
570                 return $data;
571         }
572
573         /**
574          * Creates an activity array for a given item id
575          *
576          * @param integer $item_id
577          * @param boolean $object_mode Is the activity item is used inside another object?
578          *
579          * @return array of activity
580          */
581         public static function createActivityFromItem($item_id, $object_mode = false)
582         {
583                 $item = Item::selectFirst([], ['id' => $item_id, 'parent-network' => Protocol::NATIVE_SUPPORT]);
584
585                 if (!DBA::isResult($item)) {
586                         return false;
587                 }
588
589                 $condition = ['item-uri' => $item['uri'], 'protocol' => Conversation::PARCEL_ACTIVITYPUB];
590                 $conversation = DBA::selectFirst('conversation', ['source'], $condition);
591                 if (DBA::isResult($conversation)) {
592                         $data = json_decode($conversation['source']);
593                         if (!empty($data)) {
594                                 return $data;
595                         }
596                 }
597
598                 $type = self::getTypeOfItem($item);
599
600                 if (!$object_mode) {
601                         $data = ['@context' => ActivityPub::CONTEXT];
602
603                         if ($item['deleted'] && ($item['gravity'] == GRAVITY_ACTIVITY)) {
604                                 $type = 'Undo';
605                         } elseif ($item['deleted']) {
606                                 $type = 'Delete';
607                         }
608                 } else {
609                         $data = [];
610                 }
611
612                 $data['id'] = $item['uri'] . '#' . $type;
613                 $data['type'] = $type;
614                 $data['actor'] = $item['owner-link'];
615
616                 $data['published'] = DateTimeFormat::utc($item['created'] . '+00:00', DateTimeFormat::ATOM);
617
618                 $data['instrument'] = ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()];
619
620                 $data = array_merge($data, self::createPermissionBlockForItem($item, false));
621
622                 if (in_array($data['type'], ['Create', 'Update', 'Delete'])) {
623                         $data['object'] = self::createNote($item);
624                 } elseif ($data['type'] == 'Announce') {
625                         $data['object'] = self::createAnnounce($item);
626                 } elseif ($data['type'] == 'Undo') {
627                         $data['object'] = self::createActivityFromItem($item_id, true);
628                 } else {
629                         $data['diaspora:guid'] = $item['guid'];
630                         $data['object'] = $item['thr-parent'];
631                 }
632
633                 $owner = User::getOwnerDataById($item['uid']);
634
635                 if (!$object_mode) {
636                         return LDSignature::sign($data, $owner);
637                 } else {
638                         return $data;
639                 }
640
641                 /// @todo Create "conversation" entry
642         }
643
644         /**
645          * Creates an object array for a given item id
646          *
647          * @param integer $item_id
648          *
649          * @return array with the object data
650          */
651         public static function createObjectFromItemID($item_id)
652         {
653                 $item = Item::selectFirst([], ['id' => $item_id, 'parent-network' => Protocol::NATIVE_SUPPORT]);
654
655                 if (!DBA::isResult($item)) {
656                         return false;
657                 }
658
659                 $data = ['@context' => ActivityPub::CONTEXT];
660                 $data = array_merge($data, self::createNote($item));
661
662                 return $data;
663         }
664
665         /**
666          * Creates a location entry for a given item array
667          *
668          * @param array $item
669          *
670          * @return array with location array
671          */
672         private static function createLocation($item)
673         {
674                 $location = ['type' => 'Place'];
675
676                 if (!empty($item['location'])) {
677                         $location['name'] = $item['location'];
678                 }
679
680                 $coord = [];
681
682                 if (empty($item['coord'])) {
683                         $coord = Map::getCoordinates($item['location']);
684                 } else {
685                         $coords = explode(' ', $item['coord']);
686                         if (count($coords) == 2) {
687                                 $coord = ['lat' => $coords[0], 'lon' => $coords[1]];
688                         }
689                 }
690
691                 if (!empty($coord['lat']) && !empty($coord['lon'])) {
692                         $location['latitude'] = $coord['lat'];
693                         $location['longitude'] = $coord['lon'];
694                 }
695
696                 return $location;
697         }
698
699         /**
700          * Returns a tag array for a given item array
701          *
702          * @param array $item
703          *
704          * @return array of tags
705          */
706         private static function createTagList($item)
707         {
708                 $tags = [];
709
710                 $terms = Term::tagArrayFromItemId($item['id']);
711                 foreach ($terms as $term) {
712                         if ($term['type'] == TERM_HASHTAG) {
713                                 $tags[] = ['type' => 'Hashtag', 'href' => $term['url'], 'name' => '#' . $term['term']];
714                         } elseif ($term['type'] == TERM_MENTION) {
715                                 $contact = Contact::getDetailsByURL($term['url']);
716                                 if (!empty($contact['addr'])) {
717                                         $mention = '@' . $contact['addr'];
718                                 } else {
719                                         $mention = '@' . $term['url'];
720                                 }
721
722                                 $tags[] = ['type' => 'Mention', 'href' => $term['url'], 'name' => $mention];
723                         }
724                 }
725                 return $tags;
726         }
727
728         /**
729          * Adds attachment data to the JSON document
730          *
731          * @param array $item Data of the item that is to be posted
732          * @param text $type Object type
733          *
734          * @return array with attachment data
735          */
736         private static function createAttachmentList($item, $type)
737         {
738                 $attachments = [];
739
740                 $arr = explode('[/attach],', $item['attach']);
741                 if (count($arr)) {
742                         foreach ($arr as $r) {
743                                 $matches = false;
744                                 $cnt = preg_match('|\[attach\]href=\"(.*?)\" length=\"(.*?)\" type=\"(.*?)\" title=\"(.*?)\"|', $r, $matches);
745                                 if ($cnt) {
746                                         $attributes = ['type' => 'Document',
747                                                         'mediaType' => $matches[3],
748                                                         'url' => $matches[1],
749                                                         'name' => null];
750
751                                         if (trim($matches[4]) != '') {
752                                                 $attributes['name'] = trim($matches[4]);
753                                         }
754
755                                         $attachments[] = $attributes;
756                                 }
757                         }
758                 }
759
760                 if ($type != 'Note') {
761                         return $attachments;
762                 }
763
764                 // Simplify image codes
765                 $body = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $item['body']);
766
767                 // Grab all pictures and create attachments out of them
768                 if (preg_match_all("/\[img\]([^\[\]]*)\[\/img\]/Usi", $body, $pictures)) {
769                         foreach ($pictures[1] as $picture) {
770                                 $imgdata = Image::getInfoFromURL($picture);
771                                 if ($imgdata) {
772                                         $attachments[] = ['type' => 'Document',
773                                                 'mediaType' => $imgdata['mime'],
774                                                 'url' => $picture,
775                                                 'name' => null];
776                                 }
777                         }
778                 }
779
780                 return $attachments;
781         }
782
783         /**
784          * @brief Callback function to replace a Friendica style mention in a mention that is used on AP
785          *
786          * @param array $match Matching values for the callback
787          * @return string Replaced mention
788          */
789         private static function mentionCallback($match)
790         {
791                 if (empty($match[1])) {
792                         return;
793                 }
794
795                 $data = Contact::getDetailsByURL($match[1]);
796                 if (empty($data) || empty($data['nick'])) {
797                         return;
798                 }
799
800                 return '@[url=' . $data['url'] . ']' . $data['nick'] . '[/url]';
801         }
802
803         /**
804          * Remove image elements and replaces them with links to the image
805          *
806          * @param string $body
807          *
808          * @return string with replaced elements
809          */
810         private static function removePictures($body)
811         {
812                 // Simplify image codes
813                 $body = preg_replace("/\[img\=([0-9]*)x([0-9]*)\](.*?)\[\/img\]/ism", '[img]$3[/img]', $body);
814
815                 $body = preg_replace("/\[url=([^\[\]]*)\]\[img\](.*)\[\/img\]\[\/url\]/Usi", '[url]$1[/url]', $body);
816                 $body = preg_replace("/\[img\]([^\[\]]*)\[\/img\]/Usi", '[url]$1[/url]', $body);
817
818                 return $body;
819         }
820
821         /**
822          * Fetches the "context" value for a givem item array from the "conversation" table
823          *
824          * @param array $item
825          *
826          * @return string with context url
827          */
828         private static function fetchContextURLForItem($item)
829         {
830                 $conversation = DBA::selectFirst('conversation', ['conversation-href', 'conversation-uri'], ['item-uri' => $item['parent-uri']]);
831                 if (DBA::isResult($conversation) && !empty($conversation['conversation-href'])) {
832                         $context_uri = $conversation['conversation-href'];
833                 } elseif (DBA::isResult($conversation) && !empty($conversation['conversation-uri'])) {
834                         $context_uri = $conversation['conversation-uri'];
835                 } else {
836                         $context_uri = $item['parent-uri'] . '#context';
837                 }
838                 return $context_uri;
839         }
840
841         /**
842          * Returns if the post contains sensitive content ("nsfw")
843          *
844          * @param integer $item_id
845          *
846          * @return boolean
847          */
848         private static function isSensitive($item_id)
849         {
850                 $condition = ['otype' => TERM_OBJ_POST, 'oid' => $item_id, 'type' => TERM_HASHTAG, 'term' => 'nsfw'];
851                 return DBA::exists('term', $condition);
852         }
853
854         /**
855          * Creates a note/article object array
856          *
857          * @param array $item
858          *
859          * @return array with the object data
860          */
861         public static function createNote($item)
862         {
863                 if (!empty($item['title'])) {
864                         $type = 'Article';
865                 } else {
866                         $type = 'Note';
867                 }
868
869                 if ($item['deleted']) {
870                         $type = 'Tombstone';
871                 }
872
873                 $data = [];
874                 $data['id'] = $item['uri'];
875                 $data['type'] = $type;
876
877                 if ($item['deleted']) {
878                         return $data;
879                 }
880
881                 $data['summary'] = null; // Ignore by now
882
883                 if ($item['uri'] != $item['thr-parent']) {
884                         $data['inReplyTo'] = $item['thr-parent'];
885                 } else {
886                         $data['inReplyTo'] = null;
887                 }
888
889                 $data['diaspora:guid'] = $item['guid'];
890                 $data['published'] = DateTimeFormat::utc($item['created'] . '+00:00', DateTimeFormat::ATOM);
891
892                 if ($item['created'] != $item['edited']) {
893                         $data['updated'] = DateTimeFormat::utc($item['edited'] . '+00:00', DateTimeFormat::ATOM);
894                 }
895
896                 $data['url'] = $item['plink'];
897                 $data['attributedTo'] = $item['author-link'];
898                 $data['sensitive'] = self::isSensitive($item['id']);
899                 $data['context'] = self::fetchContextURLForItem($item);
900
901                 if (!empty($item['title'])) {
902                         $data['name'] = BBCode::toPlaintext($item['title'], false);
903                 }
904
905                 $body = $item['body'];
906
907                 if ($type == 'Note') {
908                         $body = self::removePictures($body);
909                 }
910
911                 $regexp = "/[@!]\[url\=([^\[\]]*)\].*?\[\/url\]/ism";
912                 $body = preg_replace_callback($regexp, ['self', 'mentionCallback'], $body);
913
914                 $data['content'] = BBCode::convert($body, false, 7);
915                 $data['source'] = ['content' => $item['body'], 'mediaType' => "text/bbcode"];
916
917                 if (!empty($item['signed_text']) && ($item['uri'] != $item['thr-parent'])) {
918                         $data['diaspora:comment'] = $item['signed_text'];
919                 }
920
921                 $data['attachment'] = self::createAttachmentList($item, $type);
922                 $data['tag'] = self::createTagList($item);
923
924                 if (!empty($item['coord']) || !empty($item['location'])) {
925                         $data['location'] = self::createLocation($item);
926                 }
927
928                 if (!empty($item['app'])) {
929                         $data['generator'] = ['type' => 'Application', 'name' => $item['app']];
930                 }
931
932                 $data = array_merge($data, self::createPermissionBlockForItem($item, false));
933
934                 return $data;
935         }
936
937         /**
938          * Creates an announce object entry
939          *
940          * @param array $item
941          *
942          * @return string with announced object url
943          */
944         public static function createAnnounce($item)
945         {
946                 $announce = api_share_as_retweet($item);
947                 if (empty($announce['plink'])) {
948                         return self::createNote($item);
949                 }
950
951                 return $announce['plink'];
952         }
953
954         /**
955          * Transmits a contact suggestion to a given inbox
956          *
957          * @param integer $uid User ID
958          * @param string $inbox Target inbox
959          * @param integer $suggestion_id Suggestion ID
960          *
961          * @return boolean was the transmission successful?
962          */
963         public static function sendContactSuggestion($uid, $inbox, $suggestion_id)
964         {
965                 $owner = User::getOwnerDataById($uid);
966                 $profile = APContact::getByURL($owner['url']);
967
968                 $suggestion = DBA::selectFirst('fsuggest', ['url', 'note', 'created'], ['id' => $suggestion_id]);
969
970                 $data = ['@context' => 'https://www.w3.org/ns/activitystreams',
971                         'id' => System::baseUrl() . '/activity/' . System::createGUID(),
972                         'type' => 'Announce',
973                         'actor' => $owner['url'],
974                         'object' => $suggestion['url'],
975                         'content' => $suggestion['note'],
976                         'instrument' => ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()],
977                         'to' => [ActivityPub::PUBLIC_COLLECTION],
978                         'cc' => []];
979
980                 $signed = LDSignature::sign($data, $owner);
981
982                 logger('Deliver profile deletion for user ' . $uid . ' to ' . $inbox . ' via ActivityPub', LOGGER_DEBUG);
983                 return HTTPSignature::transmit($signed, $inbox, $uid);
984         }
985
986         /**
987          * Transmits a profile deletion to a given inbox
988          *
989          * @param integer $uid User ID
990          * @param string $inbox Target inbox
991          *
992          * @return boolean was the transmission successful?
993          */
994         public static function sendProfileDeletion($uid, $inbox)
995         {
996                 $owner = User::getOwnerDataById($uid);
997                 $profile = APContact::getByURL($owner['url']);
998
999                 $data = ['@context' => 'https://www.w3.org/ns/activitystreams',
1000                         'id' => System::baseUrl() . '/activity/' . System::createGUID(),
1001                         'type' => 'Delete',
1002                         'actor' => $owner['url'],
1003                         'object' => $owner['url'],
1004                         'published' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
1005                         'instrument' => ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()],
1006                         'to' => [ActivityPub::PUBLIC_COLLECTION],
1007                         'cc' => []];
1008
1009                 $signed = LDSignature::sign($data, $owner);
1010
1011                 logger('Deliver profile deletion for user ' . $uid . ' to ' . $inbox . ' via ActivityPub', LOGGER_DEBUG);
1012                 return HTTPSignature::transmit($signed, $inbox, $uid);
1013         }
1014
1015         /**
1016          * Transmits a profile change to a given inbox
1017          *
1018          * @param integer $uid User ID
1019          * @param string $inbox Target inbox
1020          *
1021          * @return boolean was the transmission successful?
1022          */
1023         public static function sendProfileUpdate($uid, $inbox)
1024         {
1025                 $owner = User::getOwnerDataById($uid);
1026                 $profile = APContact::getByURL($owner['url']);
1027
1028                 $data = ['@context' => 'https://www.w3.org/ns/activitystreams',
1029                         'id' => System::baseUrl() . '/activity/' . System::createGUID(),
1030                         'type' => 'Update',
1031                         'actor' => $owner['url'],
1032                         'object' => self::getProfile($uid),
1033                         'published' => DateTimeFormat::utcNow(DateTimeFormat::ATOM),
1034                         'instrument' => ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()],
1035                         'to' => [$profile['followers']],
1036                         'cc' => []];
1037
1038                 $signed = LDSignature::sign($data, $owner);
1039
1040                 logger('Deliver profile update for user ' . $uid . ' to ' . $inbox . ' via ActivityPub', LOGGER_DEBUG);
1041                 return HTTPSignature::transmit($signed, $inbox, $uid);
1042         }
1043
1044         /**
1045          * Transmits a given activity to a target
1046          *
1047          * @param array $activity
1048          * @param string $target Target profile
1049          * @param integer $uid User ID
1050          */
1051         public static function sendActivity($activity, $target, $uid)
1052         {
1053                 $profile = APContact::getByURL($target);
1054
1055                 $owner = User::getOwnerDataById($uid);
1056
1057                 $data = ['@context' => 'https://www.w3.org/ns/activitystreams',
1058                         'id' => System::baseUrl() . '/activity/' . System::createGUID(),
1059                         'type' => $activity,
1060                         'actor' => $owner['url'],
1061                         'object' => $profile['url'],
1062                         'instrument' => ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()],
1063                         'to' => $profile['url']];
1064
1065                 logger('Sending activity ' . $activity . ' to ' . $target . ' for user ' . $uid, LOGGER_DEBUG);
1066
1067                 $signed = LDSignature::sign($data, $owner);
1068                 HTTPSignature::transmit($signed, $profile['inbox'], $uid);
1069         }
1070
1071         /**
1072          * Transmit a message that the contact request had been accepted
1073          *
1074          * @param string $target Target profile
1075          * @param $id
1076          * @param integer $uid User ID
1077          */
1078         public static function sendContactAccept($target, $id, $uid)
1079         {
1080                 $profile = APContact::getByURL($target);
1081
1082                 $owner = User::getOwnerDataById($uid);
1083                 $data = ['@context' => 'https://www.w3.org/ns/activitystreams',
1084                         'id' => System::baseUrl() . '/activity/' . System::createGUID(),
1085                         'type' => 'Accept',
1086                         'actor' => $owner['url'],
1087                         'object' => ['id' => $id, 'type' => 'Follow',
1088                                 'actor' => $profile['url'],
1089                                 'object' => $owner['url']],
1090                         'instrument' => ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()],
1091                         'to' => $profile['url']];
1092
1093                 logger('Sending accept to ' . $target . ' for user ' . $uid . ' with id ' . $id, LOGGER_DEBUG);
1094
1095                 $signed = LDSignature::sign($data, $owner);
1096                 HTTPSignature::transmit($signed, $profile['inbox'], $uid);
1097         }
1098
1099         /**
1100          * Reject a contact request or terminates the contact relation
1101          *
1102          * @param string $target Target profile
1103          * @param $id
1104          * @param integer $uid User ID
1105          */
1106         public static function sendContactReject($target, $id, $uid)
1107         {
1108                 $profile = APContact::getByURL($target);
1109
1110                 $owner = User::getOwnerDataById($uid);
1111                 $data = ['@context' => 'https://www.w3.org/ns/activitystreams',
1112                         'id' => System::baseUrl() . '/activity/' . System::createGUID(),
1113                         'type' => 'Reject',
1114                         'actor' => $owner['url'],
1115                         'object' => ['id' => $id, 'type' => 'Follow',
1116                                 'actor' => $profile['url'],
1117                                 'object' => $owner['url']],
1118                         'instrument' => ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()],
1119                         'to' => $profile['url']];
1120
1121                 logger('Sending reject to ' . $target . ' for user ' . $uid . ' with id ' . $id, LOGGER_DEBUG);
1122
1123                 $signed = LDSignature::sign($data, $owner);
1124                 HTTPSignature::transmit($signed, $profile['inbox'], $uid);
1125         }
1126
1127         /**
1128          * Transmits a message that we don't want to follow this contact anymore
1129          *
1130          * @param string $target Target profile
1131          * @param integer $uid User ID
1132          */
1133         public static function sendContactUndo($target, $uid)
1134         {
1135                 $profile = APContact::getByURL($target);
1136
1137                 $id = System::baseUrl() . '/activity/' . System::createGUID();
1138
1139                 $owner = User::getOwnerDataById($uid);
1140                 $data = ['@context' => 'https://www.w3.org/ns/activitystreams',
1141                         'id' => $id,
1142                         'type' => 'Undo',
1143                         'actor' => $owner['url'],
1144                         'object' => ['id' => $id, 'type' => 'Follow',
1145                                 'actor' => $owner['url'],
1146                                 'object' => $profile['url']],
1147                         'instrument' => ['type' => 'Service', 'name' => BaseObject::getApp()->getUserAgent()],
1148                         'to' => $profile['url']];
1149
1150                 logger('Sending undo to ' . $target . ' for user ' . $uid . ' with id ' . $id, LOGGER_DEBUG);
1151
1152                 $signed = LDSignature::sign($data, $owner);
1153                 HTTPSignature::transmit($signed, $profile['inbox'], $uid);
1154         }
1155 }