diff --git a/activitypub.py b/activitypub.py index 460f33b..744a6db 100644 --- a/activitypub.py +++ b/activitypub.py @@ -93,7 +93,7 @@ class ActivityPub(User, Protocol): def handle(self): """Returns this user's ActivityPub address, eg ``@user@foo.com``.""" if self.obj and self.obj.as1: - addr = as2.address(self.as2()) + addr = as2.address(self.convert(self.obj)) if addr: return addr @@ -179,7 +179,7 @@ class ActivityPub(User, Protocol): logger.info(f'{obj.key} type {obj.type} is not an actor and has no author or actor with inbox') - actor = obj.as_as2() + actor = ActivityPub.convert(obj) if shared: shared_inbox = actor.get('endpoints', {}).get('sharedInbox') @@ -199,9 +199,7 @@ class ActivityPub(User, Protocol): logger.info(f'Skipping sending to blocklisted {url}') return False - orig_as2 = orig_obj.as_as2() if orig_obj else None - activity = obj.as2 or postprocess_as2(obj.as_as2(), orig_obj=orig_as2) - + activity = to_cls.convert(obj, orig_obj=to_cls.convert(orig_obj)) if not activity.get('actor'): logger.warning('Outgoing AP activity has no actor!') @@ -318,7 +316,7 @@ class ActivityPub(User, Protocol): return False @classmethod - def convert(cls, obj): + def convert(cls, obj, **kwargs): """Convert a :class:`models.Object` to AS2. Args: @@ -326,8 +324,17 @@ class ActivityPub(User, Protocol): Returns: dict: AS2 JSON + kwargs: passed through to :func:`postprocess_as2` """ - return postprocess_as2(as2.from_as1(obj.as1)) + if not obj: + return {} + + if obj.as2: + return obj.as2 + elif obj.source_protocol in ('ap', 'activitypub'): + return as2.from_as1(obj.as1) + + return postprocess_as2(as2.from_as1(obj.as1), **kwargs) @classmethod def verify_signature(cls, activity): @@ -399,7 +406,8 @@ class ActivityPub(User, Protocol): elif not key_actor or not key_actor.as1: error(f"Couldn't load {keyId} to verify signature", status=401) - key = key_actor.as_as2().get('publicKey', {}).get('publicKeyPem') + # don't ActivityPub.convert since we don't want to postprocess_as2 + key = as2.from_as1(key_actor.as1).get('publicKey', {}).get('publicKeyPem') if not key: error(f'No public key for {keyId}', status=401) @@ -524,8 +532,9 @@ def postprocess_as2(activity, orig_obj=None, wrap=True): """ if not activity or isinstance(activity, str): return activity + elif activity.keys() == {'id'}: + return activity['id'] - assert g.user type = activity.get('type') # actor objects @@ -681,10 +690,12 @@ def postprocess_as2(activity, orig_obj=None, wrap=True): if content := obj_or_activity.get('content'): obj_or_activity.setdefault('contentMap', {'en': content}) - activity['object'] = postprocess_as2( - activity.get('object'), - orig_obj=orig_obj, - wrap=wrap and type in ('Create', 'Update', 'Delete')) + activity['object'] = [ + postprocess_as2(o, orig_obj=orig_obj, + wrap=wrap and type in ('Create', 'Update', 'Delete')) + for o in as1.get_objects(activity)] + if len(activity['object']) == 1: + activity['object'] = activity['object'][0] return util.trim_nulls(activity) @@ -712,28 +723,26 @@ def postprocess_as2_actor(actor, wrap=True): urls = util.get_list(actor, 'url') if not urls and url: urls = [url] - - domain = util.domain_from_link(urls[0], minimize=False) - if wrap: + if urls and wrap: urls[0] = redirect_wrap(urls[0]) id = actor.get('id') if g.user and (not id or g.user.is_web_url(id)): actor['id'] = g.user.ap_actor() - actor.update({ - 'url': urls if len(urls) > 1 else urls[0], - # required by ActivityPub - # https://www.w3.org/TR/activitypub/#actor-objects - 'inbox': g.user.ap_actor('inbox'), - 'outbox': g.user.ap_actor('outbox'), - }) + actor['url'] = urls[0] if len(urls) == 1 else urls + # required by ActivityPub + # https://www.w3.org/TR/activitypub/#actor-objects + actor.setdefault('inbox', g.user.ap_actor('inbox')) + actor.setdefault('outbox', g.user.ap_actor('outbox')) # TODO: genericize (see line 752 in actor()) if g.user.LABEL != 'atproto': # This has to be the domain for Mastodon interop/Webfinger discovery! # See related comment in actor() below. - actor['preferredUsername'] = domain + assert urls + actor['preferredUsername'] = util.domain_from_link( + unwrap(urls[0]), minimize=False) # Override the label for their home page to be "Web site" for att in util.get_list(actor, 'attachment'): @@ -780,7 +789,7 @@ def actor(handle_or_id): if not g.user.obj or not g.user.obj.as1: g.user.obj = cls.load(g.user.profile_id(), gateway=True) - actor = g.user.as2() or { + actor = ActivityPub.convert(g.user.obj) or { '@context': [as2.CONTEXT], 'type': 'Person', } @@ -893,7 +902,7 @@ def follower_collection(id, collection): page = { 'type': 'CollectionPage', 'partOf': request.base_url, - 'items': util.trim_nulls([f.user.as2() for f in followers]), + 'items': util.trim_nulls([ActivityPub.convert(f.user.obj) for f in followers]), } if new_before: page['next'] = f'{request.base_url}?before={new_before}' diff --git a/follow.py b/follow.py index e573c79..1aefe97 100644 --- a/follow.py +++ b/follow.py @@ -110,7 +110,7 @@ class FollowCallback(indieauth.Callback): return redirect(g.user.user_page_path('following')) followee_id = followee.as1.get('id') - followee_as2 = followee.as_as2() + followee_as2 = ActivityPub.convert(followee) inbox = followee_as2.get('inbox') if not followee_id or not inbox: flash(f"AS2 profile {as2_url} missing id or inbox") @@ -122,7 +122,7 @@ class FollowCallback(indieauth.Callback): '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Follow', 'id': follow_id, - 'object': followee_as2, + 'object': followee_id, 'actor': g.user.ap_actor(), 'to': [as2.PUBLIC_AUDIENCE], } @@ -197,7 +197,7 @@ class UnfollowCallback(indieauth.Callback): followee.put() # TODO(#529): generalize - inbox = followee.as2().get('inbox') + inbox = ActivityPub.convert(followee.obj).get('inbox') if not inbox: flash(f"AS2 profile {followee_id} missing inbox") return redirect(g.user.user_page_path('following')) diff --git a/models.py b/models.py index a508a9b..129d7b0 100644 --- a/models.py +++ b/models.py @@ -306,10 +306,6 @@ class User(StringIdModel, metaclass=ProtocolUserMeta): for u in users: u._obj = keys_to_objs.get(u.obj_key) - def as2(self): - """Returns this user as an AS2 actor.""" - return self.obj.as_as2() if self.obj else {} - @ndb.ComputedProperty def handle(self): """This user's unique, human-chosen handle, eg ``@me@snarfed.org``. @@ -540,6 +536,7 @@ class Object(StringIdModel): # choices is populated in app, after all User subclasses are created, # so that PROTOCOLS is fully populated # TODO: remove? is this redundant with the protocol-specific data fields below? + # TODO: otherwise, nail down whether this is ABBREV or LABEL source_protocol = ndb.StringProperty(choices=[]) labels = ndb.StringProperty(repeated=True, choices=LABELS) @@ -805,10 +802,6 @@ class Object(StringIdModel): with self.lock: setattr(self, prop, None) - def as_as2(self): - """Returns this object as an AS2 dict.""" - return self.as2 or as2.from_as1(self.as1) or {} - def as_bsky(self, fetch_blobs=False): """Returns this object as a Bluesky record. @@ -938,7 +931,7 @@ class Object(StringIdModel): * ``object.inReplyTo`` * ``tags.[objectType=mention].url`` """ - if not self.as1 or not self.source_protocol: + if not self.as1: return # extract ids, strip Bridgy Fed subdomain URLs @@ -946,6 +939,9 @@ class Object(StringIdModel): if outer_obj != self.as1: self.our_as1 = util.trim_nulls(outer_obj) + if not self.source_protocol: + return + inner_obj = outer_obj['object'] = as1.get_object(outer_obj) fields = ['actor', 'author', 'inReplyTo'] mention_tags = [t for t in (as1.get_objects(outer_obj, 'tags') diff --git a/pages.py b/pages.py index 0e18c68..f7f948b 100644 --- a/pages.py +++ b/pages.py @@ -20,6 +20,7 @@ from oauth_dropins.webutil.flask_util import ( redirect, ) +from activitypub import ActivityPub import common from common import DOMAIN_RE from flask_app import app, cache @@ -165,6 +166,7 @@ def followers_or_following(protocol, id, collection): f'{collection}.html', address=request.args.get('address'), follow_url=request.values.get('url'), + ActivityPub=ActivityPub, **TEMPLATE_VARS, **locals(), ) diff --git a/templates/_followers.html b/templates/_followers.html index c9d0137..fae47e2 100644 --- a/templates/_followers.html +++ b/templates/_followers.html @@ -11,7 +11,7 @@ {% endif %} {% endwith %} {{ user_as1.get('displayName') or '' }} - {{ as2.address(f.user.as2() or url) or url }} + {{ as2.address(ActivityPub.convert(f.user.obj) or url) or url }} {% endwith %} diff --git a/tests/test_activitypub.py b/tests/test_activitypub.py index 382bc27..9f976f0 100644 --- a/tests/test_activitypub.py +++ b/tests/test_activitypub.py @@ -1974,10 +1974,31 @@ class ActivityPubUtilsTest(TestCase): self.assertFalse(ActivityPub.fetch(obj)) self.assertIsNone(obj.as1) - @skip def test_convert(self): - obj = Object(id='http://orig', as2=LIKE) - self.assertEqual(LIKE_WRAPPED, ActivityPub.convert(obj)) + obj = Object() + self.assertEqual({}, ActivityPub.convert(obj)) + + obj.our_as1 = {} + self.assertEqual({}, ActivityPub.convert(obj)) + + obj = Object(id='http://orig', our_as1={ + 'id': 'http://user.com/like', + 'objectType': 'activity', + 'verb': 'like', + 'actor': 'https://user.com/', + 'object': 'https://mas.to/post', + }) + self.assertEqual({ + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': 'http://localhost/r/http://user.com/like', + 'type': 'Like', + 'actor': 'http://localhost/user.com', + 'object': 'https://mas.to/post', + 'to': ['https://www.w3.org/ns/activitystreams#Public'], + }, ActivityPub.convert(obj)) + + obj.as2 = {'baz': 'biff'} + self.assertEqual({'baz': 'biff'}, ActivityPub.convert(obj)) def test_postprocess_as2_idempotent(self): g.user = self.make_user('foo.com') diff --git a/tests/test_follow.py b/tests/test_follow.py index 88287ff..2e8b9d6 100644 --- a/tests/test_follow.py +++ b/tests/test_follow.py @@ -35,13 +35,14 @@ FOLLOWEE = { 'id': 'https://bar/id', 'url': 'https://bar/url', 'inbox': 'http://bar/inbox', + 'outbox': 'http://bar/outbox', } FOLLOW_ADDRESS = { '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Follow', 'id': f'http://localhost/web/alice.com/following#2022-01-02T03:04:05-@foo@bar', 'actor': 'http://localhost/alice.com', - 'object': FOLLOWEE, + 'object': FOLLOWEE['id'], 'to': [as2.PUBLIC_AUDIENCE], } FOLLOW_URL = copy.deepcopy(FOLLOW_ADDRESS) @@ -188,10 +189,7 @@ class FollowTest(TestCase): self.check('https://bar/actor', resp, FOLLOW_URL, mock_get, mock_post) def test_callback_stored_followee_with_our_as1(self, mock_get, mock_post): - self.store_object(id='https://bar/id', our_as1=as2.to_as1({ - **FOLLOWEE, - # 'id': 'https://bar/actor', - })) + self.store_object(id='https://bar/id', our_as1=as2.to_as1(FOLLOWEE)) mock_get.side_effect = ( requests_response(''), @@ -208,14 +206,7 @@ class FollowTest(TestCase): follow_with_profile_link = { **FOLLOW_URL, 'id': f'http://localhost/web/alice.com/following#2022-01-02T03:04:05-https://bar/id', - 'object': { - **FOLLOWEE, - 'attachment': [{ - 'type': 'PropertyValue', - 'name': 'Link', - 'value': 'bar/url', - }], - }, + 'object': 'https://bar/id', } self.check('https://bar/id', resp, follow_with_profile_link, mock_get, mock_post, fetched_followee=False) @@ -246,9 +237,7 @@ class FollowTest(TestCase): state = util.encode_oauth_state(self.state) resp = self.client.get(f'/follow/callback?code=my_code&state={state}') - expected_follow = copy.deepcopy(FOLLOW_URL) - expected_follow['object'] = followee - self.check('https://bar/actor', resp, expected_follow, mock_get, mock_post) + self.check('https://bar/actor', resp, FOLLOW_URL, mock_get, mock_post) def check(self, input, resp, expected_follow, mock_get, mock_post, fetched_followee=True): @@ -321,18 +310,14 @@ class FollowTest(TestCase): id = 'http://localhost/web/www.alice.com/following#2022-01-02T03:04:05-https://bar/actor' expected_follow = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'type': 'Follow', + **FOLLOW_URL, 'id': id, 'actor': 'http://localhost/www.alice.com', - 'object': FOLLOWEE, - 'to': [as2.PUBLIC_AUDIENCE], } followee = ActivityPub(id='https://bar/id').key follow_obj = self.assert_object( id, users=[user.key, followee], status='complete', - labels=['user', 'activity'], source_protocol='ui', as2=expected_follow, - as1=as2.to_as1(expected_follow)) + labels=['user', 'activity'], source_protocol='ui', as2=expected_follow) followers = Follower.query().fetch() self.assert_entities_equal( @@ -363,11 +348,7 @@ class FollowTest(TestCase): state = util.encode_oauth_state(self.state) resp = self.client.get(f'/follow/callback?code=my_code&state={state}') - expected_follow = { - **FOLLOW_URL, - 'object': followee, - } - self.check('https://bar/actor', resp, expected_follow, mock_get, mock_post) + self.check('https://bar/actor', resp, FOLLOW_URL, mock_get, mock_post) self.assertEqual( [f'Followed https://bar/actor.'], get_flashed_messages()) diff --git a/tests/test_models.py b/tests/test_models.py index 6d04e16..182ed0a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -160,18 +160,6 @@ class UserTest(TestCase): def test_handle(self): self.assertEqual('y.z', g.user.handle) - def test_as2(self): - self.assertEqual({}, g.user.as2()) - - obj = Object(id='foo') - g.user.obj_key = obj.key # doesn't exist - self.assertEqual({}, g.user.as2()) - - del g.user._obj - obj.as2 = {'foo': 'bar'} - obj.put() - self.assertEqual({'foo': 'bar'}, g.user.as2()) - def test_id_as(self): user = self.make_user('fake:user', cls=Fake) self.assertEqual('fake:user', user.id_as(Fake)) @@ -532,26 +520,6 @@ class ObjectTest(TestCase): obj.put() self.assertEqual(['user'], obj.labels) - def test_as_as2(self): - obj = Object() - self.assertEqual({}, obj.as_as2()) - - obj.our_as1 = {} - self.assertEqual({}, obj.as_as2()) - - obj.our_as1 = { - 'objectType': 'person', - 'foo': 'bar', - } - self.assertEqual({ - '@context': 'https://www.w3.org/ns/activitystreams', - 'type': 'Person', - 'foo': 'bar', - }, obj.as_as2()) - - obj.as2 = {'baz': 'biff'} - self.assertEqual({'baz': 'biff'}, obj.as_as2()) - def test_as1_from_as2(self): self.assert_equals({ 'objectType': 'person', diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 43b3631..f90eb28 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -1432,7 +1432,7 @@ class ProtocolReceiveTest(TestCase): Fake.receive(obj) self.assert_equals({ **follow, - 'actor': 'fake:alice', + 'actor': {'id': 'fake:alice'}, 'object': 'other:bob', }, Object.get_by_id('fake:follow').our_as1) diff --git a/tests/test_web.py b/tests/test_web.py index 10a977b..b5e5e60 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -62,17 +62,6 @@ ACTOR_AS2 = { 'inbox': 'http://localhost/user.com/inbox', 'outbox': 'http://localhost/user.com/outbox', } -ACTOR_AS2_USER = { - 'type': 'Person', - 'id': 'https://user.com/', - 'url': 'https://user.com/', - 'name': 'Ms. ☕ Baz', - 'attachment': [{ - 'name': 'Ms. ☕ Baz', - 'type': 'PropertyValue', - 'value': 'user.com', - }], -} ACTOR_AS2_FULL = { **ACTOR_AS2, '@context': [ @@ -931,8 +920,8 @@ class WebTest(TestCase): self.assertEqual(('https://mas.to/inbox',), args) self.assert_equals(AS2_CREATE, json_loads(kwargs['data'])) - def test_like_stored_object_without_as2(self, mock_get, mock_post): - Object(id='https://mas.to/toot', mf2=NOTE_MF2, source_protocol='ap').put() + def test_like_stored_object(self, mock_get, mock_post): + Object(id='https://mas.to/toot', source_protocol='ap').put() Object(id='https://user.com/', mf2=ACTOR_MF2).put() mock_get.side_effect = [ LIKE, @@ -1608,15 +1597,20 @@ class WebTest(TestCase): expected_as2) # updated Web user - self.assert_user(Web, 'user.com', - obj_as2={ - **ACTOR_AS2_USER, - 'updated': '2022-01-02T03:04:05+00:00', - }, - direct=True, - has_redirects=True, - ) - + expected_actor_as2 = { + 'type': 'Person', + 'id': 'https://user.com/', + 'url': 'https://user.com/', + 'name': 'Ms. ☕ Baz', + 'attachment': [{ + 'name': 'Ms. ☕ Baz', + 'type': 'PropertyValue', + 'value': 'user.com', + }], + 'updated': '2022-01-02T03:04:05+00:00', + } + self.assert_user(Web, 'user.com', obj_as2=expected_actor_as2, direct=True, + has_redirects=True) # homepage object actor = { @@ -1841,7 +1835,7 @@ http://this/404s # preferredUsername stays y.z despite user's username. since Mastodon # queries Webfinger for preferredUsername@fed.brid.gy # https://github.com/snarfed/bridgy-fed/issues/77#issuecomment-949955109 - postprocessed = postprocess_as2(g.user.as2()) + postprocessed = ActivityPub.convert(g.user.obj) self.assertEqual('user.com', postprocessed['preferredUsername']) def test_web_url(self, _, __): diff --git a/tests/testutil.py b/tests/testutil.py index a432be4..ea38ff3 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -456,7 +456,7 @@ class TestCase(unittest.TestCase, testutil.Asserts): obj_as2 = props.pop('obj_as2', None) if obj_as2: - self.assert_equals(obj_as2, got.as2()) + self.assert_equals(obj_as2, as2.from_as1(got.obj.as1)) # generated, computed, etc ignore = ['created', 'mod', 'handle', 'obj_key', 'private_exponent',