From 28eabd07a3527da69f053b1a74daea6cbf8f7a93 Mon Sep 17 00:00:00 2001 From: Ryan Barrett Date: Wed, 31 May 2023 13:17:17 -0700 Subject: [PATCH] create AP users on inbox delivery for an indirect or nonexistent Web user for #512 --- activitypub.py | 19 ++++++++++---- models.py | 10 +++++--- tests/test_activitypub.py | 52 +++++++++++++++++++++++++++++++-------- tests/testutil.py | 23 ++++++++++++++--- 4 files changed, 82 insertions(+), 22 deletions(-) diff --git a/activitypub.py b/activitypub.py index 2d9cc3d..9366ed0 100644 --- a/activitypub.py +++ b/activitypub.py @@ -48,7 +48,10 @@ def default_signature_user(): class ActivityPub(User, Protocol): - """ActivityPub protocol class.""" + """ActivityPub protocol class. + + Key id is AP/AS2 actor id URL. (*Not* fediverse/WebFinger @-@ handle!) + """ LABEL = 'activitypub' @classmethod @@ -566,14 +569,20 @@ def inbox(protocol=None, domain=None): error(f"Couldn't parse body as non-empty JSON mapping: {body}", exc_info=True) type = activity.get('type') - actor_id = as1.get_object(activity, 'actor').get('id') + actor = as1.get_object(activity, 'actor') + actor_id = actor.get('id') logger.info(f'Got {type} from {actor_id}: {json_dumps(activity, indent=2)}') # load user + # TODO(#512) parameterize on protocol, move to Protocol if protocol and domain: - g.user = PROTOCOLS[protocol].get_by_id(domain) - if not g.user: - error(f'{protocol} user {domain} not found', status=404) + g.user = PROTOCOLS[protocol].get_by_id(domain) # receiving user + if (not g.user or not g.user.direct) and actor_id: + # this is a deliberate interaction with an indirect receiving user; + # create a local AP User for the sending user + actor_obj = ActivityPub.load(actor_id) + ActivityPub.get_or_create(actor_id, direct=True, + actor_as2=as2.from_as1(actor_obj.as1)) ActivityPub.verify_signature(activity) diff --git a/models.py b/models.py index 19f3d9f..34441ce 100644 --- a/models.py +++ b/models.py @@ -79,9 +79,9 @@ class User(StringIdModel, metaclass=ProtocolUserMeta): property: p256_key, PEM encoded https://atproto.com/guides/overview#account-portability """ - mod = ndb.StringProperty(required=True) - public_exponent = ndb.StringProperty(required=True) - private_exponent = ndb.StringProperty(required=True) + mod = ndb.StringProperty() + public_exponent = ndb.StringProperty() + private_exponent = ndb.StringProperty() p256_key = ndb.StringProperty() has_redirects = ndb.BooleanProperty() redirects_error = ndb.TextProperty() @@ -129,7 +129,8 @@ class User(StringIdModel, metaclass=ProtocolUserMeta): if user: # override direct if it's set direct = kwargs.get('direct') - if direct is not None: + if direct is not None and direct != user.direct: + logger.info(f'Setting {user.key} direct={direct}') user.direct = direct user.put() return user @@ -153,6 +154,7 @@ class User(StringIdModel, metaclass=ProtocolUserMeta): kwargs['p256_key'] = key.export_key(format='PEM') user = cls(id=id, **kwargs) + logger.info(f'Created new {user}') user.put() return user diff --git a/tests/test_activitypub.py b/tests/test_activitypub.py index d20e72a..26f6981 100644 --- a/tests/test_activitypub.py +++ b/tests/test_activitypub.py @@ -109,15 +109,21 @@ LIKE = { } LIKE_WRAPPED = copy.deepcopy(LIKE) LIKE_WRAPPED['object'] = 'http://localhost/r/https://user.com/post' -LIKE_WITH_ACTOR = copy.deepcopy(LIKE) -# TODO: use ACTOR instead -LIKE_WITH_ACTOR['actor'] = { +LIKE_ACTOR = { '@context': 'https://www.w3.org/ns/activitystreams', 'id': 'https://user.com/actor', 'type': 'Person', 'name': 'Ms. Actor', 'preferredUsername': 'msactor', - 'image': {'type': 'Image', 'url': 'https://user.com/pic.jpg'}, + 'icon': {'type': 'Image', 'url': 'https://user.com/pic.jpg'}, + 'image': [ + {'type': 'Image', 'url': 'https://user.com/thumb.jpg'}, + {'type': 'Image', 'url': 'https://user.com/pic.jpg'}, + ], +} +LIKE_WITH_ACTOR = { + **LIKE, + 'actor': LIKE_ACTOR, } # repost, should be delivered to followers if object is a fediverse post, @@ -230,8 +236,7 @@ class ActivityPubTest(TestCase): def setUp(self): super().setUp() - self.user = self.make_user('user.com', - has_hcard=True, actor_as2=ACTOR) + self.user = self.make_user('user.com', has_hcard=True, actor_as2=ACTOR) with self.request_context: self.key_id_obj = Object(id='http://my/key/id', as2={ **ACTOR, @@ -323,9 +328,23 @@ class ActivityPubTest(TestCase): got = self.client.get('/nope.com') self.assertEqual(404, got.status_code) - def test_individual_inbox_no_user(self, *mocks): - got = self.post('/nope.com/inbox', json=REPLY) - self.assertEqual(404, got.status_code) + def test_individual_inbox_no_user(self, mock_head, mock_get, mock_post): + self.user.key.delete() + + mock_get.side_effect = [self.as2_resp(LIKE_ACTOR)] + + reply = { + **REPLY, + 'actor': LIKE_ACTOR, + } + got = self._test_inbox_reply(reply, { + 'as2': reply, + 'type': 'post', + 'labels': ['activity', 'notification'], + }, mock_head, mock_get, mock_post) + + self.assert_user(ActivityPub, 'https://user.com/actor', + actor_as2=LIKE_ACTOR, direct=True) def test_inbox_activity_without_id(self, *_): note = copy.deepcopy(NOTE) @@ -362,7 +381,9 @@ class ActivityPubTest(TestCase): def _test_inbox_reply(self, reply, expected_props, mock_head, mock_get, mock_post): mock_head.return_value = requests_response(url='https://user.com/post') - mock_get.return_value = WEBMENTION_DISCOVERY + mock_get.side_effect = ( + (list(mock_get.side_effect) if mock_get.side_effect else []) + + [WEBMENTION_DISCOVERY]) mock_post.return_value = requests_response() got = self.post('/user.com/inbox', json=reply) @@ -640,6 +661,17 @@ class ActivityPubTest(TestCase): labels=['notification', 'activity'], object_ids=[LIKE['object']]) + def test_inbox_like_indirect_user_creates_User(self, mock_get, *_): + self.user.direct = False + self.user.put() + + mock_get.return_value = self.as2_resp(LIKE_ACTOR) + + self.test_inbox_like() + self.assert_user(ActivityPub, 'https://user.com/actor', + actor_as2=LIKE_ACTOR, direct=True) + + def test_inbox_follow_accept_with_id(self, *mocks): self._test_inbox_follow_accept(FOLLOW_WRAPPED, ACCEPT, *mocks) diff --git a/tests/testutil.py b/tests/testutil.py index a7d2364..c15f11b 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -86,7 +86,7 @@ with ndb_client.context(): models.reset_protocol_properties() import app -import activitypub +from activitypub import ActivityPub, CONNEG_HEADERS_AS2_HTML import common from web import Web from flask_app import app, cache, init_globals @@ -184,7 +184,7 @@ class TestCase(unittest.TestCase, testutil.Asserts): return f'com.example.record/{tid}' def get_as2(self, *args, **kwargs): - kwargs.setdefault('headers', {})['Accept'] = activitypub.CONNEG_HEADERS_AS2_HTML + kwargs.setdefault('headers', {})['Accept'] = CONNEG_HEADERS_AS2_HTML return self.client.get(*args, **kwargs) def req(self, url, **kwargs): @@ -202,7 +202,7 @@ class TestCase(unittest.TestCase, testutil.Asserts): 'Host': util.domain_from_link(url, minimize=False), 'Content-Type': 'application/activity+json', 'Digest': ANY, - **activitypub.CONNEG_HEADERS_AS2_HTML, + **CONNEG_HEADERS_AS2_HTML, **kwargs.pop('headers', {}), } return self.req(url, data=None, auth=ANY, headers=headers, @@ -250,6 +250,23 @@ class TestCase(unittest.TestCase, testutil.Asserts): ignore=['as1', 'created', 'expire', 'object_ids', 'type', 'updated']) + def assert_user(self, cls, id, **props): + got = cls.get_by_id(id) + assert got, id + + self.assert_entities_equal( + cls(id=id, **props), got, + ignore=['created', 'mod', 'p256_key', 'private_exponent', + 'public_exponent', 'updated']) + + if cls != ActivityPub: + assert got.mod + assert got.private_exponent + assert got.public_exponent + + # if cls != ATProto: + # assert got.p256_key + def assert_equals(self, expected, actual, msg=None, ignore=(), **kwargs): return super().assert_equals( expected, actual, msg=msg, ignore=tuple(ignore) + ('@context',), **kwargs)