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