Better support for "audience" / simplified Lemmy processing

This commit is contained in:
Michael 2023-06-15 22:04:28 +00:00
parent 50988bf5f1
commit 6d911a8f39
9 changed files with 133 additions and 103 deletions

View File

@ -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)) {

View File

@ -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'];

View File

@ -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;
}

View File

@ -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) {

View File

@ -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

View File

@ -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.

View File

@ -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;

View File

@ -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'];

View File

@ -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;