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': 'https://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': 'https://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': 'https://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',