From 6d911a8f395a99337d4317b2441dc529a74d9e45 Mon Sep 17 00:00:00 2001 From: Michael Date: Thu, 15 Jun 2023 22:04:28 +0000 Subject: [PATCH] Better support for "audience" / simplified Lemmy processing --- src/Model/APContact.php | 5 + src/Model/Contact.php | 2 +- src/Model/Item.php | 11 ++- src/Model/Tag.php | 2 +- src/Network/Probe.php | 1 - src/Protocol/ActivityPub/Delivery.php | 2 +- src/Protocol/ActivityPub/Processor.php | 1 + src/Protocol/ActivityPub/Receiver.php | 120 +++++++++++------------ src/Protocol/ActivityPub/Transmitter.php | 92 ++++++++++------- 9 files changed, 133 insertions(+), 103 deletions(-) diff --git a/src/Model/APContact.php b/src/Model/APContact.php index dc062fe5b5..2a5b89928f 100644 --- a/src/Model/APContact.php +++ b/src/Model/APContact.php @@ -119,6 +119,11 @@ class APContact return []; } + if (!Network::isValidHttpUrl($url) && !filter_var($url, FILTER_VALIDATE_EMAIL)) { + Logger::info('Invalid URL', ['url' => $url]); + return []; + } + $fetched_contact = []; if (empty($update)) { diff --git a/src/Model/Contact.php b/src/Model/Contact.php index 720d2638c4..1a52164561 100644 --- a/src/Model/Contact.php +++ b/src/Model/Contact.php @@ -2773,7 +2773,7 @@ class Contact } $update = false; - $guid = ($ret['guid'] ?? '') ?: Item::guidFromUri($ret['url'], $ret['baseurl'] ?: $ret['alias']); + $guid = ($ret['guid'] ?? '') ?: Item::guidFromUri($ret['url'], $ret['baseurl'] ?? $ret['alias']); // make sure to not overwrite existing values with blank entries except some technical fields $keep = ['batch', 'notify', 'poll', 'request', 'confirm', 'poco', 'baseurl']; diff --git a/src/Model/Item.php b/src/Model/Item.php index cdeb6d3d13..51ac03b4ef 100644 --- a/src/Model/Item.php +++ b/src/Model/Item.php @@ -2210,8 +2210,6 @@ class Item */ private static function tagDeliver(int $uid, int $item_id): bool { - $mention = false; - $owner = User::getOwnerDataById($uid); if (!DBA::isResult($owner)) { Logger::warning('User not found, quitting here.', ['uid' => $uid]); @@ -3664,6 +3662,7 @@ class Item /** * Does the given uri-id belongs to a post that is sent as starting post to a group? + * This does not apply to posts that are sent only in parallel to a group. * * @param int $uri_id * @@ -3671,7 +3670,13 @@ class Item */ public static function isGroupPost(int $uri_id): bool { - foreach (Tag::getByURIId($uri_id, [Tag::EXCLUSIVE_MENTION]) as $tag) { + if (Post::exists(['private' => Item::PUBLIC, 'uri-id' => $uri_id])) { + return false; + } + + foreach (Tag::getByURIId($uri_id, [Tag::EXCLUSIVE_MENTION, Tag::AUDIENCE]) as $tag) { + // @todo Possibly check for a public audience in the future, see https://socialhub.activitypub.rocks/t/fep-1b12-group-federation/2724 + // and https://codeberg.org/fediverse/fep/src/branch/main/feps/fep-1b12.md if (DBA::exists('contact', ['uid' => 0, 'nurl' => Strings::normaliseLink($tag['url']), 'contact-type' => Contact::TYPE_COMMUNITY])) { return true; } diff --git a/src/Model/Tag.php b/src/Model/Tag.php index 9f0f8d29a6..1645dc1255 100644 --- a/src/Model/Tag.php +++ b/src/Model/Tag.php @@ -487,7 +487,7 @@ class Tag * * @return boolean */ - public static function isMentioned(int $uriId, string $url, array $type = [self::MENTION, self::EXCLUSIVE_MENTION]): bool + public static function isMentioned(int $uriId, string $url, array $type = [self::MENTION, self::EXCLUSIVE_MENTION, self::AUDIENCE]): bool { $tags = self::getByURIId($uriId, $type); foreach ($tags as $tag) { diff --git a/src/Network/Probe.php b/src/Network/Probe.php index b1931ae6d3..c7b6a84e8b 100644 --- a/src/Network/Probe.php +++ b/src/Network/Probe.php @@ -341,7 +341,6 @@ class Probe * @param string $uri Address that should be probed * @param string $network Test for this specific network * @param integer $uid User ID for the probe (only used for mails) - * @param boolean $cache Use cached values? * * @return array uri data * @throws HTTPException\InternalServerErrorException diff --git a/src/Protocol/ActivityPub/Delivery.php b/src/Protocol/ActivityPub/Delivery.php index ddf5816ed5..04f5841c2e 100644 --- a/src/Protocol/ActivityPub/Delivery.php +++ b/src/Protocol/ActivityPub/Delivery.php @@ -160,7 +160,7 @@ class Delivery if (!empty($actor)) { $drop = !ActivityPub\Transmitter::sendRelayFollow($actor); Logger::notice('Resubscribed to relay', ['url' => $actor, 'success' => !$drop]); - } elseif ($cmd = ProtocolDelivery::DELETION) { + } elseif ($cmd == ProtocolDelivery::DELETION) { // Remote systems not always accept our deletion requests, so we drop them if rejected. // Situation is: In Friendica we allow the thread owner to delete foreign comments to their thread. // Most AP systems don't allow this, so they will reject the deletion request. diff --git a/src/Protocol/ActivityPub/Processor.php b/src/Protocol/ActivityPub/Processor.php index 453525454a..ac2e719718 100644 --- a/src/Protocol/ActivityPub/Processor.php +++ b/src/Protocol/ActivityPub/Processor.php @@ -1533,6 +1533,7 @@ class Processor $activity['id'] = $object['id']; $activity['to'] = $object['to'] ?? []; $activity['cc'] = $object['cc'] ?? []; + $activity['audience'] = $object['audience'] ?? []; $activity['actor'] = $actor; $activity['object'] = $object; $activity['published'] = $published; diff --git a/src/Protocol/ActivityPub/Receiver.php b/src/Protocol/ActivityPub/Receiver.php index b272c31dd3..7a07e1a7f2 100644 --- a/src/Protocol/ActivityPub/Receiver.php +++ b/src/Protocol/ActivityPub/Receiver.php @@ -291,16 +291,17 @@ class Receiver /** * Prepare the object array * - * @param array $activity Array with activity data - * @param integer $uid User ID - * @param boolean $push Message had been pushed to our system - * @param boolean $trust_source Do we trust the source? + * @param array $activity Array with activity data + * @param integer $uid User ID + * @param boolean $push Message had been pushed to our system + * @param boolean $trust_source Do we trust the source? + * @param string $original_actor Actor of the original activity. Used for receiver detection. (Optional) * * @return array with object data * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - public static function prepareObjectData(array $activity, int $uid, bool $push, bool &$trust_source): array + public static function prepareObjectData(array $activity, int $uid, bool $push, bool &$trust_source, string $original_actor = ''): array { $id = JsonLD::fetchElement($activity, '@id'); $type = JsonLD::fetchElement($activity, '@type'); @@ -319,7 +320,7 @@ class Receiver $fetched = false; if (!empty($id) && !$trust_source) { - $fetch_uid = $uid ?: self::getBestUserForActivity($activity); + $fetch_uid = $uid ?: self::getBestUserForActivity($activity, $original_actor); $fetched_activity = Processor::fetchCachedActivity($fetch_id, $fetch_uid); if (!empty($fetched_activity)) { @@ -355,7 +356,7 @@ class Receiver $type = JsonLD::fetchElement($activity, '@type'); // Fetch all receivers from to, cc, bto and bcc - $receiverdata = self::getReceivers($activity, $actor, [], false, $push || $fetched); + $receiverdata = self::getReceivers($activity, $original_actor ?: $actor, [], false, $push || $fetched); $receivers = $reception_types = []; foreach ($receiverdata as $key => $data) { $receivers[$key] = $data['uid']; @@ -379,7 +380,7 @@ class Receiver // We possibly need some user to fetch private content, // so we fetch one out of the receivers if no uid is provided. - $fetch_uid = $uid ?: self::getBestUserForActivity($activity); + $fetch_uid = $uid ?: self::getBestUserForActivity($activity, $original_actor); $object_id = JsonLD::fetchElement($activity, 'as:object', '@id'); if (empty($object_id)) { @@ -394,28 +395,6 @@ class Receiver $object_type = self::fetchObjectType($activity, $object_id, $fetch_uid); - // Fetch the activity on Lemmy "Announce" messages (announces of activities) - if (($type == 'as:Announce') && in_array($object_type, array_merge(self::ACTIVITY_TYPES, ['as:Delete', 'as:Undo', 'as:Update']))) { - Logger::debug('Fetch announced activity', ['object' => $object_id, 'uid' => $fetch_uid]); - $data = Processor::fetchCachedActivity($object_id, $fetch_uid); - if (!empty($data)) { - $type = $object_type; - $announced_activity = JsonLD::compact($data); - - // Some variables need to be refetched since the activity changed - $actor = JsonLD::fetchElement($announced_activity, 'as:actor', '@id'); - $announced_id = JsonLD::fetchElement($announced_activity, 'as:object', '@id'); - if (empty($announced_id)) { - Logger::warning('No object id in announced activity', ['id' => $object_id, 'activity' => $activity, 'announced' => $announced_activity]); - return []; - } else { - $activity = $announced_activity; - $object_id = $announced_id; - } - $object_type = self::fetchObjectType($activity, $object_id, $fetch_uid); - } - } - // Any activities on account types must not be altered if (in_array($type, ['as:Flag'])) { $object_data = []; @@ -454,7 +433,7 @@ class Receiver } elseif (in_array($type, array_merge(self::ACTIVITY_TYPES, ['as:Announce', 'as:Follow'])) && in_array($object_type, self::CONTENT_TYPES)) { // Create a mostly empty array out of the activity data (instead of the object). // This way we later don't have to check for the existence of each individual array element. - $object_data = self::processObject($activity); + $object_data = self::processObject($activity, $original_actor); $object_data['name'] = $type; $object_data['author'] = JsonLD::fetchElement($activity, 'as:actor', '@id'); $object_data['object_id'] = $object_id; @@ -598,18 +577,32 @@ class Receiver } } - // $trust_source is called by reference and is set to true if the content was retrieved successfully - $object_data = self::prepareObjectData($activity, $uid, $push, $trust_source); - if (empty($object_data)) { - Logger::info('No object data found', ['activity' => $activity]); - return true; + // Lemmy announces activities. + // To simplify the further processing, we modify the received object. + // For announced "create" activities we remove the middle layer. + // For the rest (like, dislike, update, ...) we just process the activity directly. + $original_actor = ''; + $object_type = JsonLD::fetchElement($activity['as:object'] ?? [], '@type'); + if (($type == 'as:Announce') && !empty($object_type) && !in_array($object_type, self::CONTENT_TYPES) && self::isGroup($actor)) { + $object_object_type = JsonLD::fetchElement($activity['as:object']['as:object'] ?? [], '@type'); + if (in_array($object_type, ['as:Create']) && in_array($object_object_type, self::CONTENT_TYPES)) { + Logger::debug('Replace "create" activity with inner object', ['type' => $object_type, 'object_type' => $object_object_type]); + $activity['as:object'] = $activity['as:object']['as:object']; + } elseif (in_array($object_type, array_merge(self::ACTIVITY_TYPES, ['as:Delete', 'as:Undo', 'as:Update']))) { + Logger::debug('Change announced activity to activity', ['type' => $object_type]); + $original_actor = $actor; + $type = $object_type; + $activity = $activity['as:object']; + } else { + Logger::info('Unhandled announced activity', ['type' => $object_type, 'object_type' => $object_object_type]); + } } - // Lemmy is announcing activities. - // We are changing the announces into regular activities. - if (($type == 'as:Announce') && in_array($object_data['type'] ?? '', array_merge(self::ACTIVITY_TYPES, ['as:Delete', 'as:Undo', 'as:Update']))) { - Logger::debug('Change type of announce to activity', ['type' => $object_data['type']]); - $type = $object_data['type']; + // $trust_source is called by reference and is set to true if the content was retrieved successfully + $object_data = self::prepareObjectData($activity, $uid, $push, $trust_source, $original_actor); + if (empty($object_data)) { + Logger::info('No object data found', ['activity' => $activity, 'callstack' => System::callstack(20)]); + return true; } if (!empty($body) && empty($object_data['raw'])) { @@ -688,6 +681,18 @@ class Receiver return true; } + /** + * Checks if the provided actor is a group account + * + * @param string $actor + * @return boolean + */ + private static function isGroup(string $actor): bool + { + $profile = APContact::getByURL($actor); + return ($profile['type'] ?? '') == 'Group'; + } + /** * Route activities * @@ -1009,10 +1014,10 @@ class Receiver * * @return int user id */ - public static function getBestUserForActivity(array $activity): int + public static function getBestUserForActivity(array $activity, string $actor = ''): int { $uid = 0; - $actor = JsonLD::fetchElement($activity, 'as:actor', '@id') ?? ''; + $actor = $actor ?: JsonLD::fetchElement($activity, 'as:actor', '@id') ?? ''; $receivers = self::getReceivers($activity, $actor, [], false, false); foreach ($receivers as $receiver) { @@ -1129,7 +1134,7 @@ class Receiver } // Fetch the receivers for the public and the followers collection - if ((($receiver == $followers) || (($receiver == self::PUBLIC_COLLECTION) && !$isGroup)) && !empty($actor)) { + if ((($receiver == $followers) || (($receiver == self::PUBLIC_COLLECTION) && !$isGroup) || ($isGroup && ($element == 'as:audience'))) && !empty($actor)) { $receivers = self::getReceiverForActor($actor, $tags, $receivers, $follower_target, $profile); continue; } @@ -1196,12 +1201,16 @@ class Receiver // "birdsitelive" is a service that mirrors tweets into the fediverse // These posts can be fetched without authentication, but are not marked as public // We treat them as unlisted posts to be able to handle them. + // We always process deletion activities. + $activity_type = JsonLD::fetchElement($activity, '@type'); if (empty($receivers) && $fetch_unlisted && Contact::isPlatform($actor, 'birdsitelive')) { $receivers[0] = ['uid' => 0, 'type' => self::TARGET_GLOBAL]; $receivers[-1] = ['uid' => -1, 'type' => self::TARGET_GLOBAL]; Logger::notice('Post from "birdsitelive" is set to "unlisted"', ['id' => JsonLD::fetchElement($activity, '@id')]); + } elseif (empty($receivers) && in_array($activity_type, ['as:Delete', 'as:Undo'])) { + $receivers[0] = ['uid' => 0, 'type' => self::TARGET_GLOBAL]; } elseif (empty($receivers)) { - Logger::notice('Post has got no receivers', ['fetch_unlisted' => $fetch_unlisted, 'actor' => $actor, 'id' => JsonLD::fetchElement($activity, '@id'), 'type' => JsonLD::fetchElement($activity, '@type')]); + Logger::notice('Post has got no receivers', ['fetch_unlisted' => $fetch_unlisted, 'actor' => $actor, 'id' => JsonLD::fetchElement($activity, '@id'), 'type' => $activity_type, 'callstack' => System::callstack(20)]); } return $receivers; @@ -1437,21 +1446,9 @@ class Receiver return false; } - // Lemmy is resharing "create" activities instead of content - // We fetch the content from the activity. - if (in_array($type, ['as:Create'])) { - $object = $object['as:object']; - $type = JsonLD::fetchElement($object, '@type'); - if (empty($type)) { - Logger::info('Empty type'); - return false; - } - $object_data = self::processObject($object); - } - // We currently don't handle 'pt:CacheFile', but with this step we avoid logging if (in_array($type, self::CONTENT_TYPES) || ($type == 'pt:CacheFile')) { - $object_data = self::processObject($object); + $object_data = self::processObject($object, ''); if (!empty($data)) { $object_data['raw-object'] = json_encode($data); @@ -1855,12 +1852,13 @@ class Receiver /** * Fetches data from the object part of an activity * - * @param array $object + * @param array $object + * @param string $actor * * @return array|bool Object data or FALSE if $object does not contain @id element * @throws \Exception */ - private static function processObject(array $object) + private static function processObject(array $object, string $actor) { if (!JsonLD::fetchElement($object, '@id')) { return false; @@ -1868,7 +1866,7 @@ class Receiver $object_data = self::getObjectDataFromActivity($object); - $receiverdata = self::getReceivers($object, $object_data['actor'] ?? '', $object_data['tags'], true, false); + $receiverdata = self::getReceivers($object, $actor ?: $object_data['actor'] ?? '', $object_data['tags'], true, false); $receivers = $reception_types = []; foreach ($receiverdata as $key => $data) { $receivers[$key] = $data['uid']; diff --git a/src/Protocol/ActivityPub/Transmitter.php b/src/Protocol/ActivityPub/Transmitter.php index e817198ec6..ec61eb2eb3 100644 --- a/src/Protocol/ActivityPub/Transmitter.php +++ b/src/Protocol/ActivityPub/Transmitter.php @@ -492,13 +492,12 @@ class Transmitter * Returns an array with permissions of the thread parent of the given item array * * @param array $item - * @param bool $is_group_thread * * @return array with permissions * @throws \Friendica\Network\HTTPException\InternalServerErrorException * @throws \ImagickException */ - private static function fetchPermissionBlockFromThreadParent(array $item, bool $is_group_thread): array + private static function fetchPermissionBlockFromThreadParent(array $item): array { if (empty($item['thr-parent-id'])) { return []; @@ -514,6 +513,7 @@ class Transmitter 'cc' => [], 'bto' => [], 'bcc' => [], + 'audience' => [], ]; $parent_profile = APContact::getByURL($parent['author-link']); @@ -525,12 +525,10 @@ class Transmitter $exclude[] = $item['owner-link']; } - $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) { + $type = [Tag::TO => 'to', Tag::CC => 'cc', Tag::BTO => 'bto', Tag::BCC => 'bcc', Tag::AUDIENCE => 'audience']; + foreach (Tag::getByURIId($item['thr-parent-id'], [Tag::TO, Tag::CC, Tag::BTO, Tag::BCC, Tag::AUDIENCE]) as $receiver) { if (!empty($parent_profile['followers']) && $receiver['url'] == $parent_profile['followers'] && !empty($item_profile['followers'])) { - if (!$is_group_thread) { - $permissions[$type[$receiver['type']]][] = $item_profile['followers']; - } + $permissions[$type[$receiver['type']]][] = $item_profile['followers']; } elseif (!in_array($receiver['url'], $exclude)) { $permissions[$type[$receiver['type']]][] = $receiver['url']; } @@ -600,6 +598,42 @@ class Transmitter $is_group_thread = false; } + $exclusive = false; + $mention = false; + + $parent_tags = Tag::getByURIId($item['parent-uri-id'], [Tag::AUDIENCE, Tag::MENTION]); + if (!empty($parent_tags)) { + $is_group_thread = false; + foreach ($parent_tags as $tag) { + if ($tag['type'] != Tag::AUDIENCE) { + continue; + } + $profile = APContact::getByURL($tag['url'], false); + if (!empty($profile) && ($profile['type'] == 'Group')) { + $is_group_thread = true; + } + } + if ($is_group_thread) { + foreach ($parent_tags as $tag) { + if (($tag['type'] == Tag::MENTION) && ($tag['url'] == $profile['url'])) { + $mention = false; + } + } + $exclusive = !$mention; + } + } elseif ($is_group_thread) { + foreach (Tag::getByURIId($item['parent-uri-id'], [Tag::MENTION, Tag::EXCLUSIVE_MENTION]) as $term) { + $profile = APContact::getByURL($term['url'], false); + if (!empty($profile) && ($profile['type'] == 'Group')) { + if ($term['type'] == Tag::EXCLUSIVE_MENTION) { + $exclusive = true; + } elseif ($term['type'] == Tag::MENTION) { + $mention = true; + } + } + } + } + if (self::isAnnounce($item) || self::isAPPost($last_id)) { // Will be activated in a later step $networks = Protocol::FEDERATED; @@ -616,21 +650,6 @@ class Transmitter $actor_profile = APContact::getByURL($item['author-link']); } - $exclusive = false; - $mention = false; - - if ($is_group_thread) { - foreach (Tag::getByURIId($item['parent-uri-id'], [Tag::MENTION, Tag::EXCLUSIVE_MENTION]) as $term) { - $profile = APContact::getByURL($term['url'], false); - if (!empty($profile) && ($profile['type'] == 'Group')) { - if ($term['type'] == Tag::EXCLUSIVE_MENTION) { - $exclusive = true; - } elseif ($term['type'] == Tag::MENTION) { - $mention = true; - } - } - } - } $terms = Tag::getByURIId($item['uri-id'], [Tag::MENTION, Tag::IMPLICIT_MENTION, Tag::EXCLUSIVE_MENTION]); @@ -644,7 +663,9 @@ class Transmitter $data['cc'][] = $announce['actor']['url']; } - $data = array_merge($data, self::fetchPermissionBlockFromThreadParent($item, $is_group_thread)); + if (!$is_group_thread) { + $data = array_merge($data, self::fetchPermissionBlockFromThreadParent($item)); + } // Check if the item is completely public or unlisted if ($item['private'] == Item::PUBLIC) { @@ -727,7 +748,7 @@ class Transmitter } } - if (!empty($item['parent'])) { + if (!empty($item['parent']) && (!$is_group_thread || ($item['private'] == Item::PRIVATE))) { if ($item['private'] == Item::PRIVATE) { $condition = ['parent' => $item['parent'], 'uri-id' => $item['thr-parent-id']]; } else { @@ -814,20 +835,13 @@ class Transmitter } } - $receivers = ['to' => array_values($data['to']), 'cc' => array_values($data['cc']), 'bcc' => array_values($data['bcc'])]; - - if (!empty($data['audience'])) { - $receivers['audience'] = array_values($data['audience']); - if (count($receivers['audience']) == 1) { - $receivers['audience'] = $receivers['audience'][0]; - } - } + $receivers = ['to' => array_values($data['to']), 'cc' => array_values($data['cc']), 'bcc' => array_values($data['bcc']), 'audience' => array_values($data['audience'])]; if (!$blindcopy) { unset($receivers['bcc']); } - foreach (['to' => Tag::TO, 'cc' => Tag::CC, 'bcc' => Tag::BCC] as $element => $type) { + foreach (['to' => Tag::TO, 'cc' => Tag::CC, 'bcc' => Tag::BCC, 'audience' => Tag::AUDIENCE] as $element => $type) { if (!empty($receivers[$element])) { foreach ($receivers[$element] as $receiver) { if ($receiver == ActivityPub::PUBLIC_COLLECTION) { @@ -840,6 +854,12 @@ class Transmitter } } + if (!$blindcopy && count($receivers['audience']) == 1) { + $receivers['audience'] = $receivers['audience'][0]; + } elseif (!$receivers['audience']) { + unset($receivers['audience']); + } + return $receivers; } @@ -976,7 +996,7 @@ class Transmitter $profile_uid = User::getIdForURL($item_profile['url']); - foreach (['to', 'cc', 'bto', 'bcc'] as $element) { + foreach (['to', 'cc', 'bto', 'bcc', 'audience'] as $element) { if (empty($permissions[$element])) { continue; } @@ -1000,7 +1020,7 @@ class Transmitter } else { $target = $profile['sharedinbox']; } - if (!self::archivedInbox($target)) { + if (!self::archivedInbox($target) && !in_array($contact['id'], $inboxes[$target] ?? [])) { $inboxes[$target][] = $contact['id'] ?? 0; } } @@ -1101,12 +1121,14 @@ class Transmitter unset($data['cc']); unset($data['bcc']); + unset($data['audience']); $object['to'] = $data['to']; $object['tag'] = [['type' => 'Mention', 'href' => $object['to'][0], 'name' => '']]; unset($object['cc']); unset($object['bcc']); + unset($object['audience']); $data['directMessage'] = true;