[COMPONENT][Notification] Make logic more generic and robust

Fixed various bugs

Some important concepts to bear in mind:

* Notification: Associated with activities, won't be reconstructed
together with objects, can be thought of as transient

* Attention: Associated with objects, will be reconstructed with them, can
be thought as persistent

* Notifications and Attentions have no direct implications.

* Mentions are a specific form of attentions in notes, leads to the creation of Attentions.

Finally,

Potential PHP issue detected and reported: https://github.com/php/php-src/issues/8199
`static::method()` from a non static context (such as a class method) calls `__call`, rather than
the expected `__callStatic`. Can be fixed by using `(static fn() => static::method())()`, but the
usage of the magic method is strictly unnecessary in this case.
This commit is contained in:
2022-03-13 18:23:19 +00:00
parent e1cceac150
commit 888c3798b7
32 changed files with 438 additions and 494 deletions

View File

@@ -141,14 +141,12 @@ class ActivityPub extends Plugin
$ap_actor->getActorId(),
Discovery::normalize($actor->getNickname() . '@' . parse_url($ap_actor->getInboxUri(), \PHP_URL_HOST)),
);
$already_known_ids = [];
if (!empty($ap_act->_object_mention_ids)) {
$already_known_ids = $ap_act->_object_mention_ids;
}
DB::flush();
if (Event::handle('ActivityPubNewNotification', [$actor, $ap_act->getActivity(), $already_known_ids, _m('{nickname} attentioned you.', ['{nickname}' => $actor->getNickname()])]) === Event::next) {
Event::handle('NewNotification', [$actor, $ap_act->getActivity(), $already_known_ids, _m('{nickname} attentioned you.', ['{nickname}' => $actor->getNickname()])]);
if ($ap_act->getToNotifyTargets() !== []) {
if (Event::handle('ActivityPubNewNotification', [$actor, $ap_act->getActivity(), $ap_act->getAttentionTargets(), _m('{actor_id} triggered a notification via ActivityPub.', ['{actor_id}' => $actor->getId()])]) === Event::next) {
Event::handle('NewNotification', [$actor, $ap_act->getActivity(), $ap_act->getAttentionTargets(), _m('{actor_id} triggered a notification via ActivityPub.', ['{nickname}' => $actor->getId()])]);
}
}
return Event::stop;
@@ -324,8 +322,7 @@ class ActivityPub extends Plugin
string $inbox,
array $to_actors,
array &$retry_args,
): bool
{
): bool {
try {
$data = Model::toJson($activity);
if ($sender->isGroup()) {
@@ -391,7 +388,7 @@ class ActivityPub extends Plugin
foreach ($to_addr as $inbox => $to_actors) {
Queue::enqueue(
payload: [$sender, $activity, $inbox, $to_actors],
queue: 'activitypub_postman',
queue: 'ActivitypubPostman',
priority: false,
);
}
@@ -530,7 +527,7 @@ class ActivityPub extends Plugin
*
* @return null|Actor|mixed|Note got from URI
*/
public static function getObjectByUri(string $resource, bool $try_online = true)
public static function getObjectByUri(string $resource, bool $try_online = true): mixed
{
// Try known object
$known_object = DB::findOneBy(ActivitypubObject::class, ['object_uri' => $resource], return_null: true);
@@ -544,18 +541,6 @@ class ActivityPub extends Plugin
return $known_activity->getActivity();
}
// Try local Note
if (Common::isValidHttpUrl($resource)) {
$resource_parts = parse_url($resource);
// TODO: Use URLMatcher
if ($resource_parts['host'] === Common::config('site', 'server')) {
$local_note = DB::findOneBy('note', ['url' => $resource], return_null: true);
if (!\is_null($local_note)) {
return $local_note;
}
}
}
// Try Actor
try {
return Explorer::getOneFromUri($resource, try_online: false);
@@ -563,20 +548,50 @@ class ActivityPub extends Plugin
// Ignore, this is brute forcing, it's okay not to find
}
// Try remote
if (!$try_online) {
return;
// Is it a HTTP URL?
if (Common::isValidHttpUrl($resource)) {
$resource_parts = parse_url($resource);
// If it is local
if ($resource_parts['host'] === Common::config('site', 'server')) {
// Try Local Note
$local_note = DB::findOneBy(Note::class, ['url' => $resource], return_null: true);
if (!\is_null($local_note)) {
return $local_note;
}
// Try local Activity
try {
$match = Router::match($resource_parts['path']);
$local_activity = DB::findOneBy(Activity::class, ['id' => $match['id']], return_null: true);
if (!\is_null($local_activity)) {
return $local_activity;
} else {
throw new InvalidArgumentException('Tried to retrieve a non-existent local activity.');
}
} catch (\Exception) {
// Ignore, this is brute forcing, it's okay not to find
}
throw new BugFoundException('ActivityPub failed to retrieve local resource: "' . $resource . '". This is a big issue.');
} else {
// Then it's remote
if (!$try_online) {
throw new Exception("Remote resource {$resource} not found without online resources.");
}
$response = HTTPClient::get($resource, ['headers' => self::HTTP_CLIENT_HEADERS]);
// If it was deleted
if ($response->getStatusCode() == 410) {
//$obj = Type::create('Tombstone', ['id' => $resource]);
return null;
} elseif (!HTTPClient::statusCodeIsOkay($response)) { // If it is unavailable
throw new Exception('Non Ok Status Code for given Object id.');
} else {
return Model::jsonToType($response->getContent());
}
}
}
$response = HTTPClient::get($resource, ['headers' => self::HTTP_CLIENT_HEADERS]);
// If it was deleted
if ($response->getStatusCode() == 410) {
//$obj = Type::create('Tombstone', ['id' => $resource]);
return;
} elseif (!HTTPClient::statusCodeIsOkay($response)) { // If it is unavailable
throw new Exception('Non Ok Status Code for given Object id.');
} else {
return Model::jsonToType($response->getContent());
}
return null;
}
}

View File

@@ -38,7 +38,6 @@ use function App\Core\I18n\_m;
use App\Core\Log;
use App\Core\Queue\Queue;
use App\Core\Router\Router;
use App\Entity\Actor;
use App\Util\Common;
use App\Util\Exception\ClientException;
use Exception;
@@ -164,7 +163,7 @@ class Inbox extends Controller
Queue::enqueue(
payload: [$ap_actor, $actor, $type],
queue: 'activitypub_inbox',
queue: 'ActivitypubInbox',
priority: false,
);

View File

@@ -101,27 +101,17 @@ class ActivitypubActivity extends Entity
public function getActivity(): Activity
{
return DB::findOneBy('activity', ['id' => $this->getActivityId()]);
return DB::findOneBy(Activity::class, ['id' => $this->getActivityId()]);
}
public array $_object_mention_ids = [];
public function setObjectMentionIds(array $mentions): self
public function getAttentionTargetIds(): array
{
$this->_object_mention_ids = $mentions;
return $this;
return $this->getActivity()->getAttentionTargetIds();
}
/**
* @see Entity->getNotificationTargetIds
*/
public function getNotificationTargetIds(array $ids_already_known = [], ?int $sender_id = null, bool $include_additional = true): array
public function getAttentionTargets(): array
{
// Additional actors that should know about this
if (\array_key_exists('additional', $ids_already_known)) {
return $ids_already_known['additional'];
} else {
return $this->_object_mention_ids;
}
return $this->getActivity()->getAttentionTargets();
}
public static function schemaDef(): array

View File

@@ -44,6 +44,7 @@ use App\Util\Exception\NotFoundException;
use App\Util\Exception\NotImplementedException;
use DateTimeInterface;
use InvalidArgumentException;
use const JSON_UNESCAPED_SLASHES;
use Plugin\ActivityPub\ActivityPub;
use Plugin\ActivityPub\Entity\ActivitypubActivity;
use Plugin\ActivityPub\Util\Explorer;
@@ -145,7 +146,7 @@ class Activity extends Model
*
* @throws ClientException
*/
public static function toJson(mixed $object, int $options = \JSON_UNESCAPED_SLASHES): string
public static function toJson(mixed $object, int $options = JSON_UNESCAPED_SLASHES): string
{
if ($object::class !== GSActivity::class) {
throw new InvalidArgumentException('First argument type must be an Activity.');
@@ -169,10 +170,16 @@ class Activity extends Model
'actor' => $object->getActor()->getUri(Router::ABSOLUTE_URL),
];
$attr['to'] = [];
$attr['cc'] = ['https://www.w3.org/ns/activitystreams#Public'];
foreach ($object->getAttentionTargets() as $target) {
$attr['cc'][] = $target->getUri();
}
// Get object or Tombstone
try {
$object = $object->getObject(); // Throws NotFoundException
$attr['object'] = ($attr['type'] === 'Create') ? self::jsonToType(Model::toJson($object)) : ActivityPub::getUriByObject($object);
$child = $object->getObject(); // Throws NotFoundException
$attr['object'] = ($attr['type'] === 'Create') ? self::jsonToType(Model::toJson($child)) : ActivityPub::getUriByObject($child);
} catch (NotFoundException) {
// It seems this object was deleted, refer to it as a Tombstone
$uri = match ($object->getObjectType()) {
@@ -180,18 +187,7 @@ class Activity extends Model
'actor' => Router::url('actor_view_id', ['id' => $object->getObjectId()], type: Router::ABSOLUTE_URL),
default => throw new NotImplementedException(),
};
$attr['object'] = Type::create('Tombstone', [
'id' => $uri,
]);
}
// If embedded non tombstone Object
if (!\is_string($attr['object']) && $attr['object']->get('type') !== 'Tombstone') {
// Little special case
if ($attr['type'] === 'Create' && ($attr['object']->get('type') === 'Note' || $attr['object']->get('type') === 'Page')) {
$attr['to'] = $attr['object']->get('to') ?? [];
$attr['cc'] = $attr['object']->get('cc') ?? [];
}
$attr['object'] = Type::create('Tombstone', ['id' => $uri]);
}
if (!\is_string($attr['object'])) {

View File

@@ -94,7 +94,6 @@ class ActivityCreate extends Activity
'modified' => new DateTime(),
]);
DB::persist($ap_act);
$ap_act->setObjectMentionIds($note->_object_mentions_ids);
return $ap_act;
}
}

View File

@@ -184,14 +184,24 @@ class Note extends Model
}
}
$attention_ids = [];
$explorer = new Explorer();
$attention_targets = [];
foreach ($to as $target) {
if ($target === 'https://www.w3.org/ns/activitystreams#Public') {
continue;
}
try {
$actor = Explorer::getOneFromUri($target);
$attention_ids[$actor->getId()] = $target;
try {
$actor_targets = $explorer->lookup($target);
foreach ($actor_targets as $actor) {
$attention_targets[$actor->getId()] = $actor;
}
} catch (Exception $e) {
Log::debug('ActivityPub->Model->Note->fromJson->Attention->Explorer', [$e]);
}
$actor = Explorer::getOneFromUri($target);
$attention_targets[$actor->getId()] = $actor;
// If $to is a group and note is unlisted, set note's scope as Group
if ($actor->isGroup() && $map['scope'] === 'unlisted') {
$map['scope'] = VisibilityScope::GROUP;
@@ -211,29 +221,17 @@ class Note extends Model
continue;
}
try {
$actor = Explorer::getOneFromUri($target);
$attention_ids[$actor->getId()] = $target;
} catch (Exception $e) {
Log::debug('ActivityPub->Model->Note->fromJson->getActorByUri', [$e]);
}
}
$obj = GSNote::create($map);
DB::persist($obj);
foreach ($attention_ids as $attention_uri) {
$explorer = new Explorer();
try {
$actors = $explorer->lookup($attention_uri);
foreach ($actors as $actor) {
$object_mention_ids[$target_id = $actor->getId()] = $attention_uri;
DB::persist(Attention::create(['note_id' => $obj->getId(), 'target_id' => $target_id]));
$actor_targets = $explorer->lookup($target);
foreach ($actor_targets as $actor) {
$attention_targets[$actor->getId()] = $actor;
}
} catch (Exception $e) {
Log::debug('ActivityPub->Model->Note->fromJson->Attention->Explorer', [$e]);
}
}
$attention_ids = array_keys($attention_ids);
$obj = GSNote::create($map);
DB::persist($obj);
// Attachments
$processed_attachments = [];
@@ -272,15 +270,15 @@ class Note extends Model
}
}
$object_mention_ids = [];
$mention_uris = [];
foreach ($type_note->get('tag') ?? [] as $ap_tag) {
switch ($ap_tag->get('type')) {
case 'Mention':
$explorer = new Explorer();
try {
$actors = $explorer->lookup($ap_tag->get('href'));
foreach ($actors as $actor) {
$object_mention_ids[$actor->getId()] = $ap_tag->get('href');
$mention_uris[] = $resource = $ap_tag->get('href');
$actor_targets = $explorer->lookup($resource);
foreach ($actor_targets as $actor) {
$attention_targets[$actor->getId()] = $actor;
}
} catch (Exception $e) {
Log::debug('ActivityPub->Model->Note->fromJson->Mention->Explorer', [$e]);
@@ -306,14 +304,15 @@ class Note extends Model
}
// The content would be non-sanitized text/html
Event::handle('ProcessNoteContent', [$obj, $obj->getRendered(), $obj->getContentType(), $process_note_content_extra_args = ['TagProcessed' => true, 'ignoreLinks' => $object_mention_ids]]);
Event::handle('ProcessNoteContent', [$obj, $obj->getRendered(), $obj->getContentType(), $process_note_content_extra_args = ['TagProcessed' => true, 'ignoreLinks' => $mention_uris]]);
$object_mention_ids = array_keys($object_mention_ids);
$obj->setObjectMentionsIds($object_mention_ids);
foreach ($attention_targets as $target) {
DB::persist(Attention::create(['object_type' => GSNote::schemaName(), 'object_id' => $obj->getId(), 'target_id' => $target->getId()]));
}
if ($processed_attachments !== []) {
foreach ($processed_attachments as [$a, $fname]) {
if (DB::count('actor_to_attachment', $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor_id]) === 0) {
if (DB::count(ActorToAttachment::class, $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor_id]) === 0) {
DB::persist(ActorToAttachment::create($args));
}
DB::persist(AttachmentToNote::create(['attachment_id' => $a->getId(), 'note_id' => $obj->getId(), 'title' => $fname]));
@@ -351,11 +350,11 @@ class Note extends Model
}
$attr = [
'@context' => ActivityPub::$activity_streams_two_context,
'type' => $object->getScope() === VisibilityScope::MESSAGE ? 'ChatMessage' : (match ($object->getType()) {
'note' => 'Note',
'page' => 'Page',
default => throw new \Exception('Unsupported note type.')
'@context' => ActivityPub::$activity_streams_two_context,
'type' => $object->getScope() === VisibilityScope::MESSAGE ? 'ChatMessage' : (match ($object->getType()) {
'note' => 'Note',
'page' => 'Page',
default => throw new Exception('Unsupported note type.')
}),
'id' => $object->getUrl(),
'published' => $object->getCreated()->format(DateTimeInterface::RFC3339),
@@ -370,17 +369,28 @@ class Note extends Model
'inConversation' => $object->getConversationUri(),
];
$attentions = $object->getAttentionTargets();
// Target scope
switch ($object->getScope()) {
case VisibilityScope::EVERYWHERE:
$attr['to'] = ['https://www.w3.org/ns/activitystreams#Public'];
$attr['cc'] = [Router::url('actor_subscribers_id', ['id' => $object->getActor()->getId()], Router::ABSOLUTE_URL)];
$attr['cc'] = [];
foreach ($attentions as $target) {
if ($object->getScope() === VisibilityScope::GROUP && $target->isGroup()) {
$attr['to'][] = $target->getUri(Router::ABSOLUTE_URL);
} else {
$attr['cc'][] = $target->getUri(Router::ABSOLUTE_URL);
}
}
break;
case VisibilityScope::LOCAL:
throw new ClientException('This note was not federated.', 403);
case VisibilityScope::ADDRESSEE:
case VisibilityScope::MESSAGE:
$attr['to'] = []; // Will be filled later
$attr['to'] = [];
foreach ($attentions as $target) {
$attr['to'][] = $target->getUri(Router::ABSOLUTE_URL);
}
$attr['cc'] = [];
break;
case VisibilityScope::GROUP:
@@ -391,22 +401,19 @@ class Note extends Model
// of posts. In this situation, it's safer to always send answers of type unlisted.
$attr['to'] = [];
$attr['cc'] = ['https://www.w3.org/ns/activitystreams#Public'];
foreach ($attentions as $target) {
if ($object->getScope() === VisibilityScope::GROUP && $target->isGroup()) {
$attr['to'][] = $target->getUri(Router::ABSOLUTE_URL);
} else {
$attr['cc'][] = $target->getUri(Router::ABSOLUTE_URL);
}
}
break;
default:
Log::error('ActivityPub->Note->toJson: Found an unknown visibility scope.');
throw new ServerException('Found an unknown visibility scope which cannot federate.');
}
// Notification Targets without Mentions
$attentions = $object->getNotificationTargets(ids_already_known: ['object' => []]);
foreach ($attentions as $target) {
if ($object->getScope() === VisibilityScope::GROUP && $target->isGroup()) {
$attr['to'][] = $target->getUri(Router::ABSOLUTE_URL);
} else {
$attr['cc'][] = $target->getUri(Router::ABSOLUTE_URL);
}
}
// Mentions
foreach ($object->getMentionTargets() as $mention) {
$attr['tag'][] = [