X-Git-Url: https://git.mxchange.org/?a=blobdiff_plain;f=src%2FProtocol%2FActivityPub%2FTransmitter.php;h=023bd5c83cce179c8c6cd16d2f9408b099f41f72;hb=b1e4c0931af6a1c937ea713cf70a5dbc742f30db;hp=435cab3d042276252dfa597da52a75b74a5e5487;hpb=b7bbb327394f7f4f4455fbd4f8feb63569e2cfb1;p=friendica.git diff --git a/src/Protocol/ActivityPub/Transmitter.php b/src/Protocol/ActivityPub/Transmitter.php index 435cab3d04..023bd5c83c 100644 --- a/src/Protocol/ActivityPub/Transmitter.php +++ b/src/Protocol/ActivityPub/Transmitter.php @@ -1,6 +1,6 @@ Contact::FRIEND], ['id' => $contact['id']]); + Contact::update(['rel' => Contact::FRIEND], ['id' => $contact['id']]); } return $success; @@ -138,25 +140,35 @@ class Transmitter $success = self::sendContactUndo($url, $contact['id'], 0); if ($success || $force) { - DBA::update('contact', ['rel' => Contact::NOTHING], ['id' => $contact['id']]); + Contact::update(['rel' => Contact::NOTHING], ['id' => $contact['id']]); } return $success; } - + /** * Collects a list of contacts of the given owner * - * @param array $owner Owner array - * @param int|array $rel The relevant value(s) contact.rel should match - * @param string $module The name of the relevant AP endpoint module (followers|following) - * @param integer $page Page number + * @param array $owner Owner array + * @param array $rel The relevant value(s) contact.rel should match + * @param string $module The name of the relevant AP endpoint module (followers|following) + * @param integer $page Page number + * @param string $requester URL of the requester + * @param boolean $nocache Wether to bypass caching * * @return array of owners * @throws \Exception */ - public static function getContacts($owner, $rel, $module, $page = null) + public static function getContacts(array $owner, array $rel, string $module, int $page = null, string $requester = null, $nocache = false) { + if (empty($page)) { + $cachekey = self::CACHEKEY_CONTACTS . $module . ':'. $owner['uid']; + $result = DI::cache()->get($cachekey); + if (!$nocache && !is_null($result)) { + return $result; + } + } + $parameters = [ 'rel' => $rel, 'uid' => $owner['uid'], @@ -167,25 +179,35 @@ class Transmitter 'pending' => false, 'blocked' => false, ]; - $condition = DBA::buildCondition($parameters); - $sql = "SELECT COUNT(*) as `count` - FROM `contact` - JOIN `apcontact` ON `apcontact`.`url` = `contact`.`url` - " . $condition; + $condition = DBA::mergeConditions($parameters, ["`url` IN (SELECT `url` FROM `apcontact`)"]); - $contacts = DBA::fetchFirst($sql, ...$parameters); + $total = DBA::count('contact', $condition); $modulePath = '/' . $module . '/'; $data = ['@context' => ActivityPub::CONTEXT]; $data['id'] = DI::baseUrl() . $modulePath . $owner['nickname']; $data['type'] = 'OrderedCollection'; - $data['totalItems'] = $contacts['count']; + $data['totalItems'] = $total; + + if (!empty($page)) { + $data['id'] .= '?' . http_build_query(['page' => $page]); + } // When we hide our friends we will only show the pure number but don't allow more. - $profile = Profile::getByUID($owner['uid']); - if (!empty($profile['hide-friends'])) { + $show_contacts = empty($owner['hide-friends']); + + // Allow fetching the contact list when the requester is part of the list. + if (($owner['page-flags'] == User::PAGE_FLAGS_PRVGROUP) && !empty($requester)) { + $show_contacts = DBA::exists('contact', ['nurl' => Strings::normaliseLink($requester), 'uid' => $owner['uid'], 'blocked' => false]); + } + + if (!$show_contacts) { + if (!empty($cachekey)) { + DI::cache()->set($cachekey, $data, Duration::DAY); + } + return $data; } @@ -195,22 +217,13 @@ class Transmitter $data['type'] = 'OrderedCollectionPage'; $list = []; - $sql = "SELECT `contact`.`url` - FROM `contact` - JOIN `apcontact` ON `apcontact`.`url` = `contact`.`url` - " . $condition . " - LIMIT ?, ?"; - - $parameters[] = ($page - 1) * 100; - $parameters[] = 100; - - $contacts = DBA::p($sql, ...$parameters); + $contacts = DBA::select('contact', ['url'], $condition, ['limit' => [($page - 1) * 100, 100]]); while ($contact = DBA::fetch($contacts)) { $list[] = $contact['url']; } DBA::close($contacts); - if (!empty($list)) { + if (count($list) == 100) { $data['next'] = DI::baseUrl() . $modulePath . $owner['nickname'] . '?page=' . ($page + 1); } @@ -219,6 +232,10 @@ class Transmitter $data['orderedItems'] = $list; } + if (!empty($cachekey)) { + DI::cache()->set($cachekey, $data, Duration::DAY); + } + return $data; } @@ -228,33 +245,44 @@ class Transmitter * @param array $owner Owner array * @param integer $page Page number * @param string $requester URL of requesting account + * @param boolean $nocache Wether to bypass caching * * @return array of posts * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function getOutbox($owner, $page = null, $requester = '') + public static function getOutbox(array $owner, int $page = null, string $requester = '', $nocache = false) { - $public_contact = Contact::getIdForURL($owner['url']); - $condition = ['uid' => 0, 'contact-id' => $public_contact, - 'private' => [Item::PUBLIC, Item::UNLISTED]]; + if (empty($page)) { + $cachekey = self::CACHEKEY_OUTBOX . $owner['uid']; + $result = DI::cache()->get($cachekey); + if (!$nocache && !is_null($result)) { + return $result; + } + } + + $condition = ['private' => [Item::PUBLIC, Item::UNLISTED]]; if (!empty($requester)) { $requester_id = Contact::getIdForURL($requester, $owner['uid']); if (!empty($requester_id)) { $permissionSets = DI::permissionSet()->selectByContactId($requester_id, $owner['uid']); if (!empty($permissionSets)) { - $condition = ['uid' => $owner['uid'], 'origin' => true, - 'psid' => array_merge($permissionSets->column('id'), - [DI::permissionSet()->getIdFromACL($owner['uid'], '', '', '', '')])]; + $condition = ['psid' => array_merge($permissionSets->column('id'), + [DI::permissionSet()->selectPublicForUser($owner['uid'])])]; } } } $condition = array_merge($condition, - ['author-id' => $public_contact, - 'gravity' => [GRAVITY_PARENT, GRAVITY_COMMENT], - 'deleted' => false, 'visible' => true]); + ['uid' => $owner['uid'], + 'author-id' => Contact::getIdForURL($owner['url'], 0, false), + 'gravity' => [GRAVITY_PARENT, GRAVITY_COMMENT], + 'network' => Protocol::FEDERATED, + 'parent-network' => Protocol::FEDERATED, + 'origin' => true, + 'deleted' => false, + 'visible' => true]); $count = Post::count($condition); @@ -263,14 +291,16 @@ class Transmitter $data['type'] = 'OrderedCollection'; $data['totalItems'] = $count; + if (!empty($page)) { + $data['id'] .= '?' . http_build_query(['page' => $page]); + } + if (empty($page)) { $data['first'] = DI::baseUrl() . '/outbox/' . $owner['nickname'] . '?page=1'; } else { $data['type'] = 'OrderedCollectionPage'; $list = []; - $condition['parent-network'] = Protocol::NATIVE_SUPPORT; - $items = Post::select(['id'], $condition, ['limit' => [($page - 1) * 20, 20], 'order' => ['created' => true]]); while ($item = Post::fetch($items)) { $activity = self::createActivityFromItem($item['id'], true); @@ -283,7 +313,7 @@ class Transmitter } DBA::close($items); - if (!empty($list)) { + if (count($list) == 20) { $data['next'] = DI::baseUrl() . '/outbox/' . $owner['nickname'] . '?page=' . ($page + 1); } @@ -292,6 +322,94 @@ class Transmitter $data['orderedItems'] = $list; } + if (!empty($cachekey)) { + DI::cache()->set($cachekey, $data, Duration::DAY); + } + + return $data; + } + + /** + * Public posts for the given owner + * + * @param array $owner Owner array + * @param integer $page Page number + * @param boolean $nocache Wether to bypass caching + * + * @return array of posts + * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws \ImagickException + */ + public static function getFeatured(array $owner, int $page = null, $nocache = false) + { + if (empty($page)) { + $cachekey = self::CACHEKEY_FEATURED . $owner['uid']; + $result = DI::cache()->get($cachekey); + if (!$nocache && !is_null($result)) { + return $result; + } + } + + $owner_cid = Contact::getIdForURL($owner['url'], 0, false); + + $condition = ["`uri-id` IN (SELECT `uri-id` FROM `collection-view` WHERE `cid` = ? AND `type` = ?)", + $owner_cid, Post\Collection::FEATURED]; + + $condition = DBA::mergeConditions($condition, + ['uid' => $owner['uid'], + 'author-id' => $owner_cid, + 'private' => [Item::PUBLIC, Item::UNLISTED], + 'gravity' => [GRAVITY_PARENT, GRAVITY_COMMENT], + 'network' => Protocol::FEDERATED, + 'parent-network' => Protocol::FEDERATED, + 'origin' => true, + 'deleted' => false, + 'visible' => true]); + + $count = Post::count($condition); + + $data = ['@context' => ActivityPub::CONTEXT]; + $data['id'] = DI::baseUrl() . '/featured/' . $owner['nickname']; + $data['type'] = 'OrderedCollection'; + $data['totalItems'] = $count; + + if (!empty($page)) { + $data['id'] .= '?' . http_build_query(['page' => $page]); + } + + if (empty($page)) { + $items = Post::select(['id'], $condition, ['limit' => 20, 'order' => ['created' => true]]); + } else { + $data['type'] = 'OrderedCollectionPage'; + $items = Post::select(['id'], $condition, ['limit' => [($page - 1) * 20, 20], 'order' => ['created' => true]]); + } + $list = []; + + while ($item = Post::fetch($items)) { + $activity = self::createActivityFromItem($item['id'], true); + $activity['type'] = $activity['type'] == 'Update' ? 'Create' : $activity['type']; + + // Only list "Create" activity objects here, no reshares + if (!empty($activity['object']) && ($activity['type'] == 'Create')) { + $list[] = $activity['object']; + } + } + DBA::close($items); + + if (count($list) == 20) { + $data['next'] = DI::baseUrl() . '/featured/' . $owner['nickname'] . '?page=' . ($page + 1); + } + + if (!empty($page)) { + $data['partOf'] = DI::baseUrl() . '/featured/' . $owner['nickname']; + } + + $data['orderedItems'] = $list; + + if (!empty($cachekey)) { + DI::cache()->set($cachekey, $data, Duration::DAY); + } + return $data; } @@ -310,78 +428,71 @@ class Transmitter /** * Return the ActivityPub profile of the given user * - * @param integer $uid User ID + * @param int $uid User ID * @return array with profile data - * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws HTTPException\NotFoundException + * @throws HTTPException\InternalServerErrorException */ - public static function getProfile($uid) + public static function getProfile(int $uid): array { - if ($uid != 0) { - $condition = ['uid' => $uid, 'blocked' => false, 'account_expired' => false, - 'account_removed' => false, 'verified' => true]; - $fields = ['guid', 'nickname', 'pubkey', 'account-type', 'page-flags']; - $user = DBA::selectFirst('user', $fields, $condition); - if (!DBA::isResult($user)) { - return []; - } - - $fields = ['locality', 'region', 'country-name']; - $profile = DBA::selectFirst('profile', $fields, ['uid' => $uid]); - if (!DBA::isResult($profile)) { - return []; - } - - $fields = ['name', 'url', 'location', 'about', 'avatar', 'photo']; - $contact = DBA::selectFirst('contact', $fields, ['uid' => $uid, 'self' => true]); - if (!DBA::isResult($contact)) { - return []; - } - } else { - $contact = User::getSystemAccount(); - $user = ['guid' => '', 'nickname' => $contact['nick'], 'pubkey' => $contact['pubkey'], - 'account-type' => $contact['contact-type'], 'page-flags' => User::PAGE_FLAGS_NORMAL]; - $profile = ['locality' => '', 'region' => '', 'country-name' => '']; + $owner = User::getOwnerDataById($uid); + if (!isset($owner['id'])) { + DI::logger()->error('Unable to find owner data for uid', ['uid' => $uid, 'callstack' => System::callstack(20)]); + throw new HTTPException\NotFoundException('User not found.'); } $data = ['@context' => ActivityPub::CONTEXT]; - $data['id'] = $contact['url']; + $data['id'] = $owner['url']; - if (!empty($user['guid'])) { - $data['diaspora:guid'] = $user['guid']; + if (!empty($owner['guid'])) { + $data['diaspora:guid'] = $owner['guid']; } - $data['type'] = ActivityPub::ACCOUNT_TYPES[$user['account-type']]; - + $data['type'] = ActivityPub::ACCOUNT_TYPES[$owner['account-type']]; + if ($uid != 0) { - $data['following'] = DI::baseUrl() . '/following/' . $user['nickname']; - $data['followers'] = DI::baseUrl() . '/followers/' . $user['nickname']; - $data['inbox'] = DI::baseUrl() . '/inbox/' . $user['nickname']; - $data['outbox'] = DI::baseUrl() . '/outbox/' . $user['nickname']; + $data['following'] = DI::baseUrl() . '/following/' . $owner['nick']; + $data['followers'] = DI::baseUrl() . '/followers/' . $owner['nick']; + $data['inbox'] = DI::baseUrl() . '/inbox/' . $owner['nick']; + $data['outbox'] = DI::baseUrl() . '/outbox/' . $owner['nick']; + $data['featured'] = DI::baseUrl() . '/featured/' . $owner['nick']; } else { $data['inbox'] = DI::baseUrl() . '/friendica/inbox'; } - $data['preferredUsername'] = $user['nickname']; - $data['name'] = $contact['name']; + $data['preferredUsername'] = $owner['nick']; + $data['name'] = $owner['name']; - if (!empty($profile['country-name'] . $profile['region'] . $profile['locality'])) { - $data['vcard:hasAddress'] = ['@type' => 'vcard:Home', 'vcard:country-name' => $profile['country-name'], - 'vcard:region' => $profile['region'], 'vcard:locality' => $profile['locality']]; + if (!empty($owner['country-name'] . $owner['region'] . $owner['locality'])) { + $data['vcard:hasAddress'] = ['@type' => 'vcard:Home', 'vcard:country-name' => $owner['country-name'], + 'vcard:region' => $owner['region'], 'vcard:locality' => $owner['locality']]; } - if (!empty($contact['about'])) { - $data['summary'] = BBCode::convert($contact['about'], false); + if (!empty($owner['about'])) { + $data['summary'] = BBCode::convertForUriId($owner['uri-id'] ?? 0, $owner['about'], BBCode::EXTERNAL); } - $data['url'] = $contact['url']; - $data['manuallyApprovesFollowers'] = in_array($user['page-flags'], [User::PAGE_FLAGS_NORMAL, User::PAGE_FLAGS_PRVGROUP]); - $data['publicKey'] = ['id' => $contact['url'] . '#main-key', - 'owner' => $contact['url'], - 'publicKeyPem' => $user['pubkey']]; + if (!empty($owner['xmpp']) || !empty($owner['matrix'])) { + $data['vcard:hasInstantMessage'] = []; + + if (!empty($owner['xmpp'])) { + $data['vcard:hasInstantMessage'][] = 'xmpp:' . $owner['xmpp']; + } + if (!empty($owner['matrix'])) { + $data['vcard:hasInstantMessage'][] = 'matrix:' . $owner['matrix']; + } + } + + $data['url'] = $owner['url']; + $data['manuallyApprovesFollowers'] = in_array($owner['page-flags'], [User::PAGE_FLAGS_NORMAL, User::PAGE_FLAGS_PRVGROUP]); + $data['discoverable'] = (bool)$owner['net-publish']; + $data['publicKey'] = ['id' => $owner['url'] . '#main-key', + 'owner' => $owner['url'], + 'publicKeyPem' => $owner['pubkey']]; $data['endpoints'] = ['sharedInbox' => DI::baseUrl() . '/inbox']; - $data['icon'] = ['type' => 'Image', 'url' => $contact['photo']]; + $data['icon'] = ['type' => 'Image', 'url' => User::getAvatarUrl($owner)]; - $resourceid = Photo::ridFromURI($contact['photo']); + $resourceid = Photo::ridFromURI($owner['photo']); if (!empty($resourceid)) { $photo = Photo::selectFirst(['type'], ["resource-id" => $resourceid]); if (!empty($photo['type'])) { @@ -389,10 +500,10 @@ class Transmitter } } - if (!empty($contact['header'])) { - $data['image'] = ['type' => 'Image', 'url' => $contact['header']]; + if (!empty($owner['header'])) { + $data['image'] = ['type' => 'Image', 'url' => Contact::getHeaderUrlForId($owner['id'], '', $owner['updated'])]; - $resourceid = Photo::ridFromURI($contact['header']); + $resourceid = Photo::ridFromURI($owner['header']); if (!empty($resourceid)) { $photo = Photo::selectFirst(['type'], ["resource-id" => $resourceid]); if (!empty($photo['type'])) { @@ -401,6 +512,20 @@ class Transmitter } } + $custom_fields = []; + + foreach (DI::profileField()->selectByContactId(0, $uid) as $profile_field) { + $custom_fields[] = [ + 'type' => 'PropertyValue', + 'name' => $profile_field->label, + 'value' => BBCode::convertForUriId($owner['uri-id'], $profile_field->value) + ]; + }; + + if (!empty($custom_fields)) { + $data['attachment'] = $custom_fields; + } + $data['generator'] = self::getService(); // tags: https://kitty.town/@inmysocks/100656097926961126.json @@ -425,35 +550,34 @@ class Transmitter } /** - * Returns an array with permissions of a given item array + * Returns an array with permissions of the thread parent of the given item array * * @param array $item + * @param bool $is_forum_thread * * @return array with permissions * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - private static function fetchPermissionBlockFromConversation($item) + private static function fetchPermissionBlockFromThreadParent(array $item, bool $is_forum_thread) { - if (empty($item['thr-parent'])) { + if (empty($item['thr-parent-id'])) { return []; } - $condition = ['item-uri' => $item['thr-parent'], 'protocol' => Conversation::PARCEL_ACTIVITYPUB]; - $conversation = DBA::selectFirst('conversation', ['source'], $condition); - if (!DBA::isResult($conversation)) { + $parent = Post::selectFirstPost(['author-link'], ['uri-id' => $item['thr-parent-id']]); + if (empty($parent)) { return []; } - $activity = json_decode($conversation['source'], true); + $permissions = [ + 'to' => [$parent['author-link']], + 'cc' => [], + 'bto' => [], + 'bcc' => [], + ]; - $actor = JsonLD::fetchElement($activity, 'actor', 'id'); - if (!empty($actor)) { - $permissions['to'][] = $actor; - $profile = APContact::getByURL($actor); - } else { - $profile = []; - } + $parent_profile = APContact::getByURL($parent['author-link']); $item_profile = APContact::getByURL($item['author-link']); $exclude[] = $item['author-link']; @@ -462,26 +586,17 @@ class Transmitter $exclude[] = $item['owner-link']; } - foreach (['to', 'cc', 'bto', 'bcc'] as $element) { - if (empty($activity[$element])) { - continue; - } - if (is_string($activity[$element])) { - $activity[$element] = [$activity[$element]]; - } - - foreach ($activity[$element] as $receiver) { - if (empty($receiver)) { - continue; - } - - if (!empty($profile['followers']) && $receiver == $profile['followers'] && !empty($item_profile['followers'])) { - $permissions[$element][] = $item_profile['followers']; - } elseif (!in_array($receiver, $exclude)) { - $permissions[$element][] = $receiver; + $type = [Tag::TO => 'to', Tag::CC => 'cc', Tag::BTO => 'bto', Tag::BCC => 'bcc']; + foreach (Tag::getByURIId($item['thr-parent-id'], [Tag::TO, Tag::CC, Tag::BTO, Tag::BCC]) as $receiver) { + if (!empty($parent_profile['followers']) && $receiver['url'] == $parent_profile['followers'] && !empty($item_profile['followers'])) { + if (!$is_forum_thread) { + $permissions[$type[$receiver['type']]][] = $item_profile['followers']; } + } elseif (!in_array($receiver['url'], $exclude)) { + $permissions[$type[$receiver['type']]][] = $receiver['url']; } } + return $permissions; } @@ -503,28 +618,33 @@ class Transmitter /** * Creates an array of permissions from an item thread * - * @param array $item Item array - * @param boolean $blindcopy addressing via "bcc" or "cc"? - * @param integer $last_id Last item id for adding receivers - * @param boolean $forum_mode "true" means that we are sending content to a forum + * @param array $item Item array + * @param boolean $blindcopy addressing via "bcc" or "cc"? + * @param integer $last_id Last item id for adding receivers * * @return array with permission data * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - private static function createPermissionBlockForItem($item, $blindcopy, $last_id = 0, $forum_mode = false) + private static function createPermissionBlockForItem($item, $blindcopy, $last_id = 0) { if ($last_id == 0) { $last_id = $item['id']; } $always_bcc = false; + $is_forum = false; + $follower = ''; // Check if we should always deliver our stuff via BCC if (!empty($item['uid'])) { - $profile = User::getOwnerDataById($item['uid']); - if (!empty($profile)) { - $always_bcc = $profile['hide-friends']; + $owner = User::getOwnerDataById($item['uid']); + if (!empty($owner)) { + $always_bcc = $owner['hide-friends']; + $is_forum = ($owner['account-type'] == User::ACCOUNT_TYPE_COMMUNITY) && $owner['manually-approve']; + + $profile = APContact::getByURL($owner['url'], false); + $follower = $profile['followers'] ?? ''; } } @@ -532,6 +652,14 @@ class Transmitter $always_bcc = true; } + $parent = Post::selectFirst(['causer-link', 'post-reason'], ['id' => $item['parent']]); + if (($parent['post-reason'] == Item::PR_ANNOUNCEMENT) && !empty($parent['causer-link'])) { + $profile = APContact::getByURL($parent['causer-link'], false); + $is_forum_thread = isset($profile['type']) && $profile['type'] == 'Group'; + } else { + $is_forum_thread = false; + } + if (self::isAnnounce($item) || DI::config()->get('debug', 'total_ap_delivery') || self::isAPPost($last_id)) { // Will be activated in a later step $networks = Protocol::FEDERATED; @@ -562,7 +690,7 @@ class Transmitter $data['cc'][] = $announce['actor']['url']; } - $data = array_merge($data, self::fetchPermissionBlockFromConversation($item)); + $data = array_merge($data, self::fetchPermissionBlockFromThreadParent($item, $is_forum_thread)); // Check if the item is completely public or unlisted if ($item['private'] == Item::PUBLIC) { @@ -594,30 +722,41 @@ class Transmitter continue; } - if (!empty($profile = APContact::getByURL($contact['url'], false))) { + $profile = APContact::getByURL($term['url'], false); + if (!empty($profile)) { + if ($term['type'] == Tag::EXCLUSIVE_MENTION) { + $exclusive = true; + if (!empty($profile['followers']) && ($profile['type'] == 'Group')) { + $data['cc'][] = $profile['followers']; + } + } $data['to'][] = $profile['url']; } } } - foreach ($receiver_list as $receiver) { - $contact = DBA::selectFirst('contact', ['url', 'hidden', 'network', 'protocol', 'gsid'], ['id' => $receiver, 'network' => Protocol::FEDERATED]); - if (!DBA::isResult($contact) || !self::isAPContact($contact, $networks)) { - continue; - } + if ($is_forum && !$exclusive && !empty($follower)) { + $data['cc'][] = $follower; + } elseif (!$exclusive) { + foreach ($receiver_list as $receiver) { + $contact = DBA::selectFirst('contact', ['url', 'hidden', 'network', 'protocol', 'gsid'], ['id' => $receiver, 'network' => Protocol::FEDERATED]); + if (!DBA::isResult($contact) || !self::isAPContact($contact, $networks)) { + continue; + } - if (!empty($profile = APContact::getByURL($contact['url'], false))) { - if ($contact['hidden'] || $always_bcc) { - $data['bcc'][] = $profile['url']; - } else { - $data['cc'][] = $profile['url']; + if (!empty($profile = APContact::getByURL($contact['url'], false))) { + if ($contact['hidden'] || $always_bcc) { + $data['bcc'][] = $profile['url']; + } else { + $data['cc'][] = $profile['url']; + } } } } } if (!empty($item['parent'])) { - $parents = Post::select(['id', 'author-link', 'owner-link', 'gravity', 'uri'], ['parent' => $item['parent']]); + $parents = Post::select(['id', 'author-link', 'owner-link', 'gravity', 'uri'], ['parent' => $item['parent']], ['order' => ['id']]); while ($parent = Post::fetch($parents)) { if ($parent['gravity'] == GRAVITY_PARENT) { $profile = APContact::getByURL($parent['owner-link'], false); @@ -631,15 +770,13 @@ class Transmitter $data['to'][] = $profile['url']; } else { $data['cc'][] = $profile['url']; - if (($item['private'] != Item::PRIVATE) && !empty($actor_profile['followers'])) { + if (($item['private'] != Item::PRIVATE) && !empty($actor_profile['followers'])&& !$is_forum_thread) { $data['cc'][] = $actor_profile['followers']; } } - } elseif (!$exclusive) { + } elseif (!$exclusive && !$is_forum_thread) { // Public thread parent post always are directed to the followers. - // This mustn't be done by posts that are directed to forum servers via the exclusive mention. - // But possibly in that case we could add the "followers" collection of the forum to the message. - if (($item['private'] != Item::PRIVATE) && !$forum_mode) { + if ($item['private'] != Item::PRIVATE) { $data['cc'][] = $actor_profile['followers']; } } @@ -701,6 +838,19 @@ class Transmitter unset($receivers['bcc']); } + foreach (['to' => Tag::TO, 'cc' => Tag::CC, 'bcc' => Tag::BCC] as $element => $type) { + if (!empty($receivers[$element])) { + foreach ($receivers[$element] as $receiver) { + if ($receiver == ActivityPub::PUBLIC_COLLECTION) { + $name = Receiver::PUBLIC_COLLECTION; + } else { + $name = trim(parse_url($receiver, PHP_URL_PATH), '/'); + } + Tag::store($item['uri-id'], $type, $name, $receiver); + } + } + } + return $receivers; } @@ -719,10 +869,10 @@ class Transmitter /** * Check if a given contact should be delivered via AP * - * @param array $contact - * @param array $networks - * @return bool - * @throws Exception + * @param array $contact + * @param array $networks + * @return bool + * @throws Exception */ private static function isAPContact(array $contact, array $networks) { @@ -805,18 +955,17 @@ class Transmitter /** * Fetches an array of inboxes for the given item and user * - * @param array $item Item array - * @param integer $uid User ID - * @param boolean $personal fetch personal inboxes - * @param integer $last_id Last item id for adding receivers - * @param boolean $forum_mode "true" means that we are sending content to a forum + * @param array $item Item array + * @param integer $uid User ID + * @param boolean $personal fetch personal inboxes + * @param integer $last_id Last item id for adding receivers * @return array with inboxes * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function fetchTargetInboxes($item, $uid, $personal = false, $last_id = 0, $forum_mode = false) + public static function fetchTargetInboxes($item, $uid, $personal = false, $last_id = 0) { - $permissions = self::createPermissionBlockForItem($item, true, $last_id, $forum_mode); + $permissions = self::createPermissionBlockForItem($item, true, $last_id); if (empty($permissions)) { return []; } @@ -886,6 +1035,9 @@ class Transmitter } $reply = DBA::selectFirst('mail', ['uri', 'uri-id', 'from-url'], ['parent-uri' => $mail['parent-uri'], 'reply' => false]); + if (!DBA::isResult($reply)) { + $reply = $mail; + } // Making the post more compatible for Mastodon by: // - Making it a note and not an article (no title) @@ -896,6 +1048,7 @@ class Transmitter $mail['title'] = ''; } + $mail['content-warning'] = ''; $mail['author-link'] = $mail['owner-link'] = $mail['from-url']; $mail['owner-id'] = $mail['author-id']; $mail['allow_cid'] = '<'.$mail['contact-id'].'>'; @@ -1070,20 +1223,6 @@ class Transmitter return false; } - // In case of a forum post ensure to return the original post if author and forum are on the same machine - if (($item['gravity'] == GRAVITY_PARENT) && !empty($item['forum_mode'])) { - $author = Contact::getById($item['author-id'], ['nurl']); - if (!empty($author['nurl'])) { - $self = Contact::selectFirst(['uid'], ['nurl' => $author['nurl'], 'self' => true]); - if (!empty($self['uid'])) { - $forum_item = Post::selectFirst(Item::DELIVER_FIELDLIST, ['uri-id' => $item['uri-id'], 'uid' => $self['uid']]); - if (DBA::isResult($forum_item)) { - $item = $forum_item; - } - } - } - } - if (empty($item['uri-id'])) { Logger::warning('Item without uri-id', ['item' => $item]); return false; @@ -1152,6 +1291,7 @@ class Transmitter if (in_array($data['type'], ['Create', 'Update', 'Delete'])) { $data['object'] = $object ?? self::createNote($item); + $data['published'] = DateTimeFormat::utcNow(DateTimeFormat::ATOM); } elseif ($data['type'] == 'Add') { $data = self::createAddTag($item, $data); } elseif ($data['type'] == 'Announce') { @@ -1291,75 +1431,36 @@ class Transmitter $urls = []; foreach ($uriids as $uriid) { - foreach (Post\Media::getByURIId($uriid, [Post\Media::DOCUMENT, Post\Media::TORRENT]) as $attachment) { + foreach (Post\Media::getByURIId($uriid, [Post\Media::AUDIO, Post\Media::IMAGE, Post\Media::VIDEO, Post\Media::DOCUMENT, Post\Media::TORRENT]) as $attachment) { if (in_array($attachment['url'], $urls)) { continue; } $urls[] = $attachment['url']; - $attachments[] = ['type' => 'Document', + $attach = ['type' => 'Document', 'mediaType' => $attachment['mimetype'], 'url' => $attachment['url'], 'name' => $attachment['description']]; - } - } - if ($type != 'Note') { - return $attachments; - } + if (!empty($attachment['height'])) { + $attach['height'] = $attachment['height']; + } - foreach ($uriids as $uriid) { - foreach (Post\Media::getByURIId($uriid, [Post\Media::AUDIO, Post\Media::IMAGE, Post\Media::VIDEO]) as $attachment) { - if (in_array($attachment['url'], $urls)) { - continue; + if (!empty($attachment['width'])) { + $attach['width'] = $attachment['width']; } - $urls[] = $attachment['url']; - $attachments[] = ['type' => 'Document', - 'mediaType' => $attachment['mimetype'], - 'url' => $attachment['url'], - 'name' => $attachment['description']]; - } - // Currently deactivated, since it creates side effects on Mastodon and Pleroma. - // It will be activated, once this cleared. - /* - foreach (Post\Media::getByURIId($uriid, [Post\Media::HTML]) as $attachment) { - if (in_array($attachment['url'], $urls)) { - continue; + if (!empty($attachment['preview'])) { + $attach['image'] = $attachment['preview']; } - $urls[] = $attachment['url']; - $attachments[] = ['type' => 'Page', - 'mediaType' => $attachment['mimetype'], - 'url' => $attachment['url'], - 'name' => $attachment['description']]; - }*/ + $attachments[] = $attach; + } } return $attachments; } - /** - * Callback function to replace a Friendica style mention in a mention that is used on AP - * - * @param array $match Matching values for the callback - * @return string Replaced mention - * @throws \Friendica\Network\HTTPException\InternalServerErrorException - */ - private static function mentionCallback($match) - { - if (empty($match[1])) { - return ''; - } - - $data = Contact::getByURL($match[1], false, ['url', 'alias', 'nick']); - if (empty($data['nick'])) { - return $match[0]; - } - - return '[url=' . $data['url'] . ']@' . $data['nick'] . '[/url]'; - } - /** * Callback function to replace a Friendica style mention in a mention for a summary * @@ -1445,7 +1546,7 @@ class Transmitter */ private static function isSensitive($uri_id) { - return DBA::exists('tag-view', ['uri-id' => $uri_id, 'name' => 'nsfw']); + return DBA::exists('tag-view', ['uri-id' => $uri_id, 'name' => 'nsfw', 'type' => Tag::HASHTAG]); } /** @@ -1460,11 +1561,11 @@ class Transmitter { $event = []; $event['name'] = $item['event-summary']; - $event['content'] = BBCode::convert($item['event-desc'], false, BBCode::ACTIVITYPUB); - $event['startTime'] = DateTimeFormat::utc($item['event-start'] . '+00:00', DateTimeFormat::ATOM); + $event['content'] = BBCode::convertForUriId($item['uri-id'], $item['event-desc'], BBCode::ACTIVITYPUB); + $event['startTime'] = DateTimeFormat::utc($item['event-start'], 'c'); if (!$item['event-nofinish']) { - $event['endTime'] = DateTimeFormat::utc($item['event-finish'] . '+00:00', DateTimeFormat::ATOM); + $event['endTime'] = DateTimeFormat::utc($item['event-finish'], 'c'); } if (!empty($item['event-location'])) { @@ -1472,7 +1573,8 @@ class Transmitter $event['location'] = self::createLocation($item); } - $event['dfrn:adjust'] = (bool)$item['event-adjust']; + // 2021.12: Backward compatibility value, all the events now "adjust" to the viewer timezone + $event['dfrn:adjust'] = true; return $event; } @@ -1492,10 +1594,28 @@ class Transmitter return []; } + // We are treating posts differently when they are directed to a community. + // This is done to better support Lemmy. Most of the changes should work with other systems as well. + // But to not risk compatibility issues we currently perform the changes only for communities. + if ($item['gravity'] == GRAVITY_PARENT) { + $isCommunityPost = !empty(Tag::getByURIId($item['uri-id'], [Tag::EXCLUSIVE_MENTION])); + $links = Post\Media::getByURIId($item['uri-id'], [Post\Media::HTML]); + if ($isCommunityPost && (count($links) == 1)) { + $link = $links[0]['url']; + } + } else { + $isCommunityPost = false; + } + if ($item['event-type'] == 'event') { $type = 'Event'; } elseif (!empty($item['title'])) { - $type = 'Article'; + if (!$isCommunityPost || empty($link)) { + $type = 'Article'; + } else { + // "Page" is used by Lemmy for posts that contain an external link + $type = 'Page'; + } } else { $type = 'Note'; } @@ -1527,7 +1647,7 @@ class Transmitter $data['updated'] = DateTimeFormat::utc($item['edited'] . '+00:00', DateTimeFormat::ATOM); } - $data['url'] = $item['plink']; + $data['url'] = $link ?? $item['plink']; $data['attributedTo'] = $item['author-link']; $data['sensitive'] = self::isSensitive($item['uri-id']); $data['context'] = self::fetchContextURLForItem($item); @@ -1549,7 +1669,7 @@ class Transmitter * This part is currently deactivated. The automated summary seems to be more * confusing than helping. But possibly we will find a better way. * So the code is left here for now as a reminder - * + * * } elseif (($type == 'Article') && empty($data['summary'])) { * $regexp = "/[@!]\[url\=([^\[\]]*)\].*?\[\/url\]/ism"; * $summary = preg_replace_callback($regexp, ['self', 'mentionAddrCallback'], $body); @@ -1564,10 +1684,22 @@ class Transmitter if ($type == 'Event') { $data = array_merge($data, self::createEvent($item)); } else { - $regexp = "/[@!]\[url\=([^\[\]]*)\].*?\[\/url\]/ism"; - $body = preg_replace_callback($regexp, ['self', 'mentionCallback'], $body); + if ($isCommunityPost) { + // For community posts we remove the visible "!user@domain.tld". + // This improves the look at systems like Lemmy. + // Also in the future we should control the community delivery via other methods. + $body = preg_replace("/!\[url\=[^\[\]]*\][^\[\]]*\[\/url\]/ism", '', $body); + } + + if ($type == 'Page') { + // When we transmit "Page" posts we have to remove the attachment. + // The attachment contains the link that we already transmit in the "url" field. + $body = preg_replace("/\s*\[attachment .*?\].*?\[\/attachment\]\s*/ism", '', $body); + } + + $body = BBCode::setMentionsToNicknames($body); - $data['content'] = BBCode::convert($body, false, BBCode::ACTIVITYPUB); + $data['content'] = BBCode::convertForUriId($item['uri-id'], $body, BBCode::ACTIVITYPUB); } // The regular "content" field does contain a minimized HTML. This is done since systems like @@ -1575,11 +1707,10 @@ class Transmitter // The contentMap does contain the unmodified HTML. $language = self::getLanguage($item); if (!empty($language)) { - $regexp = "/[@!]\[url\=([^\[\]]*)\].*?\[\/url\]/ism"; - $richbody = preg_replace_callback($regexp, ['self', 'mentionCallback'], $item['body']); + $richbody = BBCode::setMentionsToNicknames($item['body'] ?? ''); $richbody = BBCode::removeAttachment($richbody); - $data['contentMap'][$language] = BBCode::convert($richbody, false, BBCode::EXTERNAL); + $data['contentMap'][$language] = BBCode::convertForUriId($item['uri-id'], $richbody, BBCode::EXTERNAL); } $data['source'] = ['content' => $item['body'], 'mediaType' => "text/bbcode"]; @@ -1781,7 +1912,7 @@ class Transmitter { $owner = User::getOwnerDataById($uid); - $suggestion = DI::fsuggest()->getById($suggestion_id); + $suggestion = DI::fsuggest()->selectOneById($suggestion_id); $data = ['@context' => ActivityPub::CONTEXT, 'id' => DI::baseUrl() . '/activity/' . System::createGUID(), @@ -1795,7 +1926,7 @@ class Transmitter $signed = LDSignature::sign($data, $owner); - Logger::log('Deliver profile deletion for user ' . $uid . ' to ' . $inbox . ' via ActivityPub', Logger::DEBUG); + Logger::info('Deliver profile deletion for user ' . $uid . ' to ' . $inbox . ' via ActivityPub'); return HTTPSignature::transmit($signed, $inbox, $uid); } @@ -1824,7 +1955,7 @@ class Transmitter $signed = LDSignature::sign($data, $owner); - Logger::log('Deliver profile relocation for user ' . $uid . ' to ' . $inbox . ' via ActivityPub', Logger::DEBUG); + Logger::info('Deliver profile relocation for user ' . $uid . ' to ' . $inbox . ' via ActivityPub'); return HTTPSignature::transmit($signed, $inbox, $uid); } @@ -1863,7 +1994,7 @@ class Transmitter $signed = LDSignature::sign($data, $owner); - Logger::log('Deliver profile deletion for user ' . $uid . ' to ' . $inbox . ' via ActivityPub', Logger::DEBUG); + Logger::info('Deliver profile deletion for user ' . $uid . ' to ' . $inbox . ' via ActivityPub'); return HTTPSignature::transmit($signed, $inbox, $uid); } @@ -1874,10 +2005,11 @@ class Transmitter * @param string $inbox Target inbox * * @return boolean was the transmission successful? - * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @throws HTTPException\InternalServerErrorException + * @throws HTTPException\NotFoundException * @throws \ImagickException */ - public static function sendProfileUpdate($uid, $inbox) + public static function sendProfileUpdate(int $uid, string $inbox): bool { $owner = User::getOwnerDataById($uid); $profile = APContact::getByURL($owner['url']); @@ -1894,7 +2026,7 @@ class Transmitter $signed = LDSignature::sign($data, $owner); - Logger::log('Deliver profile update for user ' . $uid . ' to ' . $inbox . ' via ActivityPub', Logger::DEBUG); + Logger::info('Deliver profile update for user ' . $uid . ' to ' . $inbox . ' via ActivityPub'); return HTTPSignature::transmit($signed, $inbox, $uid); } @@ -1931,7 +2063,7 @@ class Transmitter 'instrument' => self::getService(), 'to' => [$profile['url']]]; - Logger::log('Sending activity ' . $activity . ' to ' . $target . ' for user ' . $uid, Logger::DEBUG); + Logger::info('Sending activity ' . $activity . ' to ' . $target . ' for user ' . $uid); $signed = LDSignature::sign($data, $owner); return HTTPSignature::transmit($signed, $profile['inbox'], $uid); @@ -1970,7 +2102,7 @@ class Transmitter $condition = ['verb' => Activity::FOLLOW, 'uid' => 0, 'parent-uri' => $object, 'author-id' => Contact::getPublicIdByUserId($uid)]; if (Post::exists($condition)) { - Logger::log('Follow for ' . $object . ' for user ' . $uid . ' does already exist.', Logger::DEBUG); + Logger::info('Follow for ' . $object . ' for user ' . $uid . ' does already exist.'); return false; } @@ -1984,7 +2116,7 @@ class Transmitter 'instrument' => self::getService(), 'to' => [$profile['url']]]; - Logger::log('Sending follow ' . $object . ' to ' . $target . ' for user ' . $uid, Logger::DEBUG); + Logger::info('Sending follow ' . $object . ' to ' . $target . ' for user ' . $uid); $signed = LDSignature::sign($data, $owner); return HTTPSignature::transmit($signed, $profile['inbox'], $uid); @@ -2033,15 +2165,16 @@ class Transmitter * @param string $target Target profile * @param $id * @param integer $uid User ID - * @throws \Friendica\Network\HTTPException\InternalServerErrorException + * @return bool Operation success + * @throws HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function sendContactReject($target, $id, $uid) + public static function sendContactReject($target, $id, $uid): bool { $profile = APContact::getByURL($target); if (empty($profile['inbox'])) { Logger::warning('No inbox found for target', ['target' => $target, 'profile' => $profile]); - return; + return false; } $owner = User::getOwnerDataById($uid); @@ -2061,7 +2194,7 @@ class Transmitter Logger::debug('Sending reject to ' . $target . ' for user ' . $uid . ' with id ' . $id); $signed = LDSignature::sign($data, $owner); - HTTPSignature::transmit($signed, $profile['inbox'], $uid); + return HTTPSignature::transmit($signed, $profile['inbox'], $uid); } /** @@ -2100,7 +2233,7 @@ class Transmitter 'instrument' => self::getService(), 'to' => [$profile['url']]]; - Logger::log('Sending undo to ' . $target . ' for user ' . $uid . ' with id ' . $id, Logger::DEBUG); + Logger::info('Sending undo to ' . $target . ' for user ' . $uid . ' with id ' . $id); $signed = LDSignature::sign($data, $owner); return HTTPSignature::transmit($signed, $profile['inbox'], $uid);