From 83f179989e2c84cad43efe69c901b3f1c16c3524 Mon Sep 17 00:00:00 2001 From: tenma Date: Tue, 13 Aug 2019 00:05:51 +0100 Subject: [PATCH] [AP] Handle unlisted/followers-only notices Note that this commit isn't intended to add support for sending such notes in GS. Instead, we handle the reception, storage and direct reply to this type of notices, in AP. ActivityPubPlugin: - Subscribe the event StartNoticeSave to hack answering non-public notes Activitypub_create: - Add 'directMessage' attribute to the Create activity, defaulting to false for now - Update validation method: validate 'directMessage' and add debug Activitypub_notice: - Handle incoming unlisted/followers-only notes - Add support for unlisted-replies - Add method to verify private (direct) notices inbox_handler: - Add handler for CREATE Note - Prepare logic for private-messaging - Overall refactor: Class members were continuously being passed as function arguments without need SharePlugin: - Stop showing the announce button in non public posts --- modules/Share/ShareModule.php | 17 ++- plugins/ActivityPub/ActivityPubPlugin.php | 30 ++++ plugins/ActivityPub/lib/inbox_handler.php | 137 +++++++++--------- .../lib/models/Activitypub_create.php | 23 ++- .../lib/models/Activitypub_notice.php | 80 ++++++++-- 5 files changed, 188 insertions(+), 99 deletions(-) diff --git a/modules/Share/ShareModule.php b/modules/Share/ShareModule.php index 19eeeb95b3..7c41bbbc8b 100644 --- a/modules/Share/ShareModule.php +++ b/modules/Share/ShareModule.php @@ -230,22 +230,31 @@ class ShareModule extends ActivityVerbHandlerModule */ public function onEndShowNoticeOptionItems($nli) { + $notice = $nli->notice; + + // We shouldn't be restricting Shares for received unlisted notices, + // but without subscription_policy working we treat both this type + // and followers-only notices the same, so we also restrict both. + if (!$notice->isPublic()) { + return; + } + // FIXME: Use bitmasks (but be aware that PUBLIC_SCOPE is 0!) // Also: AHHH, $scope and $scoped are scarily similar looking. - $scope = $nli->notice->getScope(); + $scope = $notice->getScope(); if ($scope === Notice::PUBLIC_SCOPE || $scope === Notice::SITE_SCOPE) { $scoped = Profile::current(); if ($scoped instanceof Profile && - $scoped->getID() !== $nli->notice->getProfile()->getID()) { + $scoped->getID() !== $notice->getProfile()->getID()) { - if ($scoped->hasRepeated($nli->notice)) { + if ($scoped->hasRepeated($notice)) { $nli->out->element('span', array('class' => 'repeated', // TRANS: Title for repeat form status in notice list when a notice has been repeated. 'title' => _('Notice repeated.')), // TRANS: Repeat form status in notice list when a notice has been repeated. _('Repeated')); } else { - $repeat = new RepeatForm($nli->out, $nli->notice); + $repeat = new RepeatForm($nli->out, $notice); $repeat->show(); } } diff --git a/plugins/ActivityPub/ActivityPubPlugin.php b/plugins/ActivityPub/ActivityPubPlugin.php index d5afd63116..35d5af8faf 100644 --- a/plugins/ActivityPub/ActivityPubPlugin.php +++ b/plugins/ActivityPub/ActivityPubPlugin.php @@ -256,6 +256,36 @@ class ActivityPubPlugin extends Plugin return true; } + /** + * Update notice before saving. + * We'll use this as a hack to maintain replies to unlisted/followers-only + * notices away from the public timelines. + * + * @param Notice &$notice notice to be saved + * @return bool event hook return + */ + public function onStartNoticeSave(Notice &$notice): bool { + if ($notice->reply_to) { + try { + $parent = $notice->getParent(); + $is_local = (int)$parent->is_local; + + // if we're replying unlisted/followers-only notices received by AP + // or replying to replies of such notices, then we make sure to set + // the correct type flag. + if ( ($parent->source === 'ActivityPub' && $is_local === Notice::GATEWAY) || + ($parent->source === 'web' && $is_local === Notice::LOCAL_NONPUBLIC) ) { + $this->log(LOG_INFO, "Enforcing type flag LOCAL_NONPUBLIC for new notice"); + $notice->is_local = Notice::LOCAL_NONPUBLIC; + } + } catch (NoParentNoticeException $e) { + // This is not a reply to something (has no parent) + } + } + + return true; + } + /** * Plugin Nodeinfo information * diff --git a/plugins/ActivityPub/lib/inbox_handler.php b/plugins/ActivityPub/lib/inbox_handler.php index 41956dd50e..f1e3a231dc 100644 --- a/plugins/ActivityPub/lib/inbox_handler.php +++ b/plugins/ActivityPub/lib/inbox_handler.php @@ -122,25 +122,25 @@ class Activitypub_inbox_handler { switch ($this->activity['type']) { case 'Accept': - $this->handle_accept($this->actor, $this->object); + $this->handle_accept(); break; case 'Create': - $this->handle_create($this->actor, $this->object); + $this->handle_create(); break; case 'Delete': - $this->handle_delete($this->actor, $this->object); + $this->handle_delete(); break; case 'Follow': - $this->handle_follow($this->actor, $this->activity); + $this->handle_follow(); break; case 'Like': - $this->handle_like($this->actor, $this->object); + $this->handle_like(); break; case 'Undo': - $this->handle_undo($this->actor, $this->object); + $this->handle_undo(); break; case 'Announce': - $this->handle_announce($this->actor, $this->object); + $this->handle_announce(); break; } } @@ -148,18 +148,16 @@ class Activitypub_inbox_handler /** * Handles an Accept Activity received by our inbox. * - * @param Profile $actor Actor - * @param array $object Activity * @throws HTTP_Request2_Exception * @throws NoProfileException * @throws ServerException * @author Diogo Cordeiro */ - private function handle_accept($actor, $object) + private function handle_accept() { - switch ($object['type']) { + switch ($this->object['type']) { case 'Follow': - $this->handle_accept_follow($actor, $object); + $this->handle_accept_follow(); break; } } @@ -167,54 +165,63 @@ class Activitypub_inbox_handler /** * Handles an Accept Follow Activity received by our inbox. * - * @param Profile $actor Actor - * @param array $object Activity * @throws HTTP_Request2_Exception * @throws NoProfileException * @throws ServerException * @author Diogo Cordeiro */ - private function handle_accept_follow($actor, $object) + private function handle_accept_follow() { // Get valid Object profile // Note that, since this an accept_follow, the $object // profile is actually the actor that followed someone $object_profile = new Activitypub_explorer; - $object_profile = $object_profile->lookup($object['object'])[0]; + $object_profile = $object_profile->lookup($this->object['object'])[0]; - Activitypub_profile::subscribeCacheUpdate($object_profile, $actor); + Activitypub_profile::subscribeCacheUpdate($object_profile, $this->actor); - $pending_list = new Activitypub_pending_follow_requests($object_profile->getID(), $actor->getID()); + $pending_list = new Activitypub_pending_follow_requests($object_profile->getID(), $this->actor->getID()); $pending_list->remove(); } /** * Handles a Create Activity received by our inbox. * - * @param Profile $actor Actor - * @param array $object Activity * @throws Exception * @author Diogo Cordeiro */ - private function handle_create($actor, $object) + private function handle_create() { - switch ($object['type']) { + switch ($this->object['type']) { case 'Note': - Activitypub_notice::create_notice($object, $actor); + $this->handle_create_note(); break; } } + /** + * Handle a Create Note Activity received by our inbox. + * + * @author Bruno Casteleiro + */ + private function handle_create_note() + { + if (Activitypub_notice::isPrivateNote($this->activity)) { + // Plugin DirectMessage must handle this + } else { + Activitypub_notice::create_notice($this->object, $this->actor); + } + } + /** * Handles a Delete Activity received by our inbox. * - * @param Profile $actor Actor - * @param array|string $object Activity's object * @throws AuthorizationException * @author Diogo Cordeiro */ - private function handle_delete(Profile $actor, $object) + private function handle_delete() { + $object = $this->object; if (is_array($object)) { $object = $object['id']; } @@ -230,15 +237,13 @@ class Activitypub_inbox_handler if (!$deleted) { $notice = ActivityPubPlugin::grab_notice_from_url($object); - $notice->deleteAs($actor); + $notice->deleteAs($this->actor); } } /** * Handles a Follow Activity received by our inbox. * - * @param Profile $actor Actor - * @param array $activity Activity * @throws AlreadyFulfilledException * @throws HTTP_Request2_Exception * @throws NoProfileException @@ -247,100 +252,90 @@ class Activitypub_inbox_handler * @throws \HttpSignatures\Exception * @author Diogo Cordeiro */ - private function handle_follow($actor, $activity) + private function handle_follow() { - Activitypub_follow::follow($actor, $activity['object'], $activity['id']); + Activitypub_follow::follow($this->actor, $this->object, $this->activity['id']); } /** * Handles a Like Activity received by our inbox. * - * @param Profile $actor Actor - * @param array $object Activity * @throws Exception * @author Diogo Cordeiro */ - private function handle_like($actor, $object) + private function handle_like() { - $notice = ActivityPubPlugin::grab_notice_from_url($object); - Fave::addNew($actor, $notice); + $notice = ActivityPubPlugin::grab_notice_from_url($this->object); + Fave::addNew($this->actor, $notice); } /** * Handles a Undo Activity received by our inbox. * - * @param Profile $actor Actor - * @param array $object Activity * @throws AlreadyFulfilledException * @throws HTTP_Request2_Exception * @throws NoProfileException * @throws ServerException * @author Diogo Cordeiro */ - private function handle_undo($actor, $object) + private function handle_undo() { - switch ($object['type']) { + switch ($this->object['type']) { case 'Follow': - $this->handle_undo_follow($actor, $object['object']); + $this->handle_undo_follow(); break; case 'Like': - $this->handle_undo_like($actor, $object['object']); + $this->handle_undo_like(); break; } } - /** - * Handles a Undo Like Activity received by our inbox. - * - * @param Profile $actor Actor - * @param array $object Activity - * @throws AlreadyFulfilledException - * @throws ServerException - * @author Diogo Cordeiro - */ - private function handle_undo_like($actor, $object) - { - $notice = ActivityPubPlugin::grab_notice_from_url($object); - Fave::removeEntry($actor, $notice); - } - /** * Handles a Undo Follow Activity received by our inbox. * - * @author Diogo Cordeiro - * @param Profile $actor Actor - * @param array $object Activity * @throws AlreadyFulfilledException * @throws HTTP_Request2_Exception * @throws NoProfileException * @throws ServerException + * @author Diogo Cordeiro */ - private function handle_undo_follow($actor, $object) + private function handle_undo_follow() { // Get Object profile $object_profile = new Activitypub_explorer; - $object_profile = $object_profile->lookup($object)[0]; + $object_profile = $object_profile->lookup($this->object['object'])[0]; - if (Subscription::exists($actor, $object_profile)) { - Subscription::cancel($actor, $object_profile); + if (Subscription::exists($this->actor, $object_profile)) { + Subscription::cancel($this->actor, $object_profile); // You are no longer following this person. - Activitypub_profile::unsubscribeCacheUpdate($actor, $object_profile); + Activitypub_profile::unsubscribeCacheUpdate($this->actor, $object_profile); } else { // 409: You are not following this person already. } } + /** + * Handles a Undo Like Activity received by our inbox. + * + * @throws AlreadyFulfilledException + * @throws ServerException + * @author Diogo Cordeiro + */ + private function handle_undo_like() + { + $notice = ActivityPubPlugin::grab_notice_from_url($this->object['object']); + Fave::removeEntry($this->actor, $notice); + } + /** * Handles a Announce Activity received by our inbox. * - * @author Diogo Cordeiro - * @param Profile $actor Actor - * @param array $object Activity * @throws Exception + * @author Diogo Cordeiro */ - private function handle_announce($actor, $object) + private function handle_announce() { - $object_notice = ActivityPubPlugin::grab_notice_from_url($object); - $object_notice->repeat($actor, 'ActivityPub'); + $object_notice = ActivityPubPlugin::grab_notice_from_url($this->object); + $object_notice->repeat($this->actor, 'ActivityPub'); } } diff --git a/plugins/ActivityPub/lib/models/Activitypub_create.php b/plugins/ActivityPub/lib/models/Activitypub_create.php index 6ad1c8e9a1..6af6253995 100644 --- a/plugins/ActivityPub/lib/models/Activitypub_create.php +++ b/plugins/ActivityPub/lib/models/Activitypub_create.php @@ -44,16 +44,17 @@ class Activitypub_create * @param array $object * @return array pretty array to be used in a response */ - public static function create_to_array($actor, $object) + public static function create_to_array(string $actor, array $object): array { $res = [ - '@context' => 'https://www.w3.org/ns/activitystreams', - 'id' => $object['id'].'/create', - 'type' => 'Create', - 'to' => $object['to'], - 'cc' => $object['cc'], - 'actor' => $actor, - 'object' => $object + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => $object['id'].'/create', + 'type' => 'Create', + 'directMessage' => false, + 'to' => $object['to'], + 'cc' => $object['cc'], + 'actor' => $actor, + 'object' => $object ]; return $res; } @@ -68,11 +69,17 @@ class Activitypub_create public static function validate_object($object) { if (!is_array($object)) { + common_debug('ActivityPub Create Validator: Rejected because of invalid Object format.'); throw new Exception('Invalid Object Format for Create Activity.'); } if (!isset($object['type'])) { + common_debug('ActivityPub Create Validator: Rejected because of Type.'); throw new Exception('Object type was not specified for Create Activity.'); } + if (isset($object['directMessage']) && !is_bool($object['directMessage'])) { + common_debug('ActivityPub Create Validator: Rejected because Object directMessage is invalid.'); + throw new Exception('Invalid Object directMessage.'); + } switch ($object['type']) { case 'Note': // Validate data diff --git a/plugins/ActivityPub/lib/models/Activitypub_notice.php b/plugins/ActivityPub/lib/models/Activitypub_notice.php index 4d7f45f86a..46893be4e5 100644 --- a/plugins/ActivityPub/lib/models/Activitypub_notice.php +++ b/plugins/ActivityPub/lib/models/Activitypub_notice.php @@ -61,13 +61,22 @@ class Activitypub_notice } } - $to = ['https://www.w3.org/ns/activitystreams#Public']; - foreach ($notice->getAttentionProfiles() as $to_profile) { - $to[] = $href = $to_profile->getUri(); - $tags[] = Activitypub_mention_tag::mention_tag_to_array_from_values($href, $to_profile->getNickname().'@'.parse_url($href, PHP_URL_HOST)); + if ($notice->isPublic()) { + $to = ['https://www.w3.org/ns/activitystreams#Public']; + $cc = [common_local_url('apActorFollowers', ['id' => $profile->getID()])]; + } else { + // Since we currently don't support sending unlisted/followers-only + // notices, arriving here means we're instead answering to that type + // of posts. Not having subscription policy working, its safer to + // always send answers of type unlisted. + $to = []; + $cc = ['https://www.w3.org/ns/activitystreams#Public']; } - $cc = [common_local_url('apActorFollowers', ['id' => $profile->getID()])]; + foreach ($notice->getAttentionProfiles() as $to_profile) { + $to[] = $href = $to_profile->getUri(); + $tags[] = Activitypub_mention_tag::mention_tag_to_array_from_values($href, $to_profile->getNickname().'@'.parse_url($href, PHP_URL_HOST)); + } $item = [ '@context' => 'https://www.w3.org/ns/activitystreams', @@ -108,11 +117,11 @@ class Activitypub_notice * * @author Diogo Cordeiro * @param array $object - * @param Profile|null $actor_profile + * @param Profile $actor_profile * @return Notice * @throws Exception */ - public static function create_notice($object, $actor_profile = null) + public static function create_notice(array $object, Profile $actor_profile = null) { $id = $object['id']; // int $url = isset($object['url']) ? $object['url'] : $id; // string @@ -140,7 +149,10 @@ class Activitypub_notice $act->time = time(); $act->actor = $actor_profile->asActivityObject(); $act->context = new ActivityContext(); - $options = ['source' => 'ActivityPub', 'uri' => $id, 'url' => $url]; + $options = ['source' => 'ActivityPub', + 'uri' => $id, + 'url' => $url, + 'is_local' => self::getNotePolicyType($object, $actor_profile)]; // Is this a reply? if (isset($settings['inReplyTo'])) { @@ -238,9 +250,9 @@ class Activitypub_notice common_debug('ActivityPub Notice Validator: Rejected because Object URL is invalid.'); throw new Exception('Invalid Object URL.'); } - if (!(isset($object['to']) || isset($object['cc']))) { - common_debug('ActivityPub Notice Validator: Rejected because neither Object CC and TO were specified.'); - throw new Exception('Neither Object CC and TO were specified.'); + if (!(isset($object['to']) && isset($object['cc']))) { + common_debug('ActivityPub Notice Validator: Rejected because either Object CC or TO wasn\'t specified.'); + throw new Exception('Either Object CC or TO wasn\'t specified.'); } return true; } @@ -253,10 +265,46 @@ class Activitypub_notice * @author Bruno Casteleiro */ public static function getUrl(Notice $notice): string { - if ($notice->isLocal()) { - return common_local_url('apNotice', ['id' => $notice->getID()]); - } else { - return $notice->getUrl(); - } + if ($notice->isLocal()) { + return common_local_url('apNotice', ['id' => $notice->getID()]); + } else { + return $notice->getUrl(); + } + } + + /** + * Extract note policy type from note targets. + * + * @param array $note received Note + * @param Profile $actor_profile Note author + * @return int Notice policy type + * @author Bruno Casteleiro + */ + public static function getNotePolicyType(array $note, Profile $actor_profile): int { + if (in_array('https://www.w3.org/ns/activitystreams#Public', $note['to'])) { + return $actor_profile->isLocal() ? Notice::LOCAL_PUBLIC : Notice::REMOTE; + } else { + // either an unlisted or followers-only note, we'll handle + // both as a GATEWAY notice since this type is not visible + // from the public timelines, hence partially enough while + // we don't have subscription_policy working. + return Notice::GATEWAY; + } + } + + /** + * Verify if received note is private (direct). + * Note that we're conformant with the (yet) non-standard directMessage attribute: + * https://github.com/w3c/activitypub/issues/196#issuecomment-304958984 + * + * @param array $activity received Create-Note activity + * @return bool true if note is private, false otherwise + */ + public static function isPrivateNote(array $activity): bool { + if (isset($activity['directMessage'])) { + return $activity['directMessage']; + } + + return empty($activity['cc']) && !in_array('https://www.w3.org/ns/activitystreams#Public', $activity['to']); } }