kopia lustrzana https://github.com/snarfed/bridgy-fed
convert activitypub.py logic to use all AS1
rodzic
1ab7aab13d
commit
2dfddec2ef
249
activitypub.py
249
activitypub.py
|
@ -24,23 +24,26 @@ from models import Follower, Object, Target, User
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_TYPES = (
|
||||
'Accept',
|
||||
'Announce',
|
||||
'Article',
|
||||
'Audio',
|
||||
'Create',
|
||||
'Delete',
|
||||
'Follow',
|
||||
'Image',
|
||||
'Like',
|
||||
'Note',
|
||||
'Undo',
|
||||
'Update',
|
||||
'Video',
|
||||
SUPPORTED_TYPES = ( # AS1
|
||||
'accept',
|
||||
'article',
|
||||
'audio',
|
||||
'comment',
|
||||
'create',
|
||||
'delete',
|
||||
'follow',
|
||||
'image',
|
||||
'like',
|
||||
'note',
|
||||
'post',
|
||||
'share',
|
||||
'stop-following',
|
||||
'undo',
|
||||
'update',
|
||||
'video',
|
||||
)
|
||||
FETCH_OBJECT_TYPES = (
|
||||
'Announce',
|
||||
'share',
|
||||
)
|
||||
|
||||
# activity ids that we've already handled and can now ignore
|
||||
|
@ -103,19 +106,13 @@ def inbox(domain=None):
|
|||
# parse and validate AS2 activity
|
||||
try:
|
||||
activity = request.json
|
||||
assert activity
|
||||
assert activity and isinstance(activity, dict)
|
||||
except (TypeError, ValueError, AssertionError):
|
||||
error(f"Couldn't parse body as JSON: {body}", exc_info=True)
|
||||
error(f"Couldn't parse body as non-empty JSON mapping: {body}", exc_info=True)
|
||||
|
||||
|
||||
type = activity.get('type')
|
||||
actor = activity.get('actor')
|
||||
actor_id = actor.get('id') if isinstance(actor, dict) else actor
|
||||
logger.info(f'Got {type} activity from {actor_id}: {json_dumps(activity, indent=2)}')
|
||||
|
||||
obj_as2 = activity.get('object') or {}
|
||||
if isinstance(obj_as2, str):
|
||||
obj_as2 = {'id': obj_as2}
|
||||
logger.info(f'Got {activity.get("type")} activity from {actor_id}: {json_dumps(activity, indent=2)}')
|
||||
|
||||
id = activity.get('id')
|
||||
if not id:
|
||||
|
@ -130,114 +127,125 @@ def inbox(domain=None):
|
|||
logger.info(msg)
|
||||
return msg, 200
|
||||
|
||||
activity_unwrapped = redirect_unwrap(activity)
|
||||
activity_obj = Object(id=id, as2=activity_unwrapped,
|
||||
source_protocol='activitypub')
|
||||
activity_obj.put()
|
||||
obj = Object(id=id, as2=redirect_unwrap(activity), source_protocol='activitypub')
|
||||
obj.put()
|
||||
|
||||
if type == 'Accept': # eg in response to a Follow
|
||||
return '' # noop
|
||||
if type not in SUPPORTED_TYPES:
|
||||
error(f'Sorry, {type} activities are not supported yet.', status=501)
|
||||
if obj.type == 'accept': # eg in response to a Follow
|
||||
return 'OK' # noop
|
||||
elif obj.type not in SUPPORTED_TYPES:
|
||||
error(f'Sorry, {obj.type} activities are not supported yet.', status=501)
|
||||
|
||||
inner_obj = obj.as1.get('object') or {}
|
||||
if isinstance(inner_obj, str):
|
||||
inner_obj = {'id': inner_obj}
|
||||
inner_obj_id = inner_obj.get('id')
|
||||
|
||||
# load user
|
||||
user = None
|
||||
if domain:
|
||||
user = User.get_by_id(domain)
|
||||
if not user:
|
||||
return f'User {domain} not found', 404
|
||||
error(f'User {domain} not found', status=404)
|
||||
|
||||
verify_signature(user)
|
||||
|
||||
# handle activity!
|
||||
if type == 'Undo' and obj_as2.get('type') == 'Follow':
|
||||
# skip actor fetch below; we don't need it to undo a follow
|
||||
undo_follow(activity_unwrapped)
|
||||
activity_obj.status = 'complete'
|
||||
activity_obj.put()
|
||||
return 'OK'
|
||||
if obj.type == 'stop-following':
|
||||
# granary doesn't yet handle three-actor undo follows, eg Eve undoes
|
||||
# Alice following Bob
|
||||
follower = as1.get_object(as1.get_object(activity, 'object'), 'actor')
|
||||
assert actor_id == follower.get('id')
|
||||
|
||||
elif type == 'Update':
|
||||
obj_id = obj_as2.get('id')
|
||||
if not obj_id:
|
||||
error("Couldn't find obj_id of object to update")
|
||||
if not actor_id or not inner_obj_id:
|
||||
error(f'Undo of Follow requires object with actor and object. Got: {actor_id} {followee} {obj.as1}')
|
||||
|
||||
obj = Object.get_by_id(obj_id) or Object(id=obj_id)
|
||||
obj.populate(as2=obj_as2, source_protocol='activitypub')
|
||||
# deactivate Follower
|
||||
followee_domain = util.domain_from_link(inner_obj_id, minimize=False)
|
||||
follower = Follower.get_by_id(Follower._id(dest=followee_domain, src=actor_id))
|
||||
if follower:
|
||||
logging.info(f'Marking {follower} inactive')
|
||||
follower.status = 'inactive'
|
||||
follower.put()
|
||||
else:
|
||||
logger.warning(f'No Follower found for {followee_domain} {actor_id}')
|
||||
|
||||
# TODO send webmention with 410 of u-follow
|
||||
|
||||
obj.status = 'complete'
|
||||
obj.put()
|
||||
|
||||
activity_obj.status = 'complete'
|
||||
activity_obj.put()
|
||||
return 'OK'
|
||||
|
||||
elif type == 'Delete':
|
||||
obj_id = obj_as2.get('id')
|
||||
if not obj_id:
|
||||
elif obj.type == 'update':
|
||||
if not inner_obj_id:
|
||||
error("Couldn't find id of object to update")
|
||||
|
||||
to_update = Object.get_by_id(inner_obj_id) or Object(id=inner_obj_id)
|
||||
to_update.populate(as2=obj.as2.get('object'), source_protocol='activitypub')
|
||||
to_update.put()
|
||||
|
||||
obj.status = 'complete'
|
||||
obj.put()
|
||||
return 'OK'
|
||||
|
||||
elif obj.type == 'delete':
|
||||
if not inner_obj_id:
|
||||
error("Couldn't find id of object to delete")
|
||||
|
||||
obj = Object.get_by_id(obj_id)
|
||||
if obj:
|
||||
logger.info(f'Marking Object {obj_id} deleted')
|
||||
obj.deleted = True
|
||||
obj.put()
|
||||
to_delete = Object.get_by_id(inner_obj_id)
|
||||
if to_delete:
|
||||
logger.info(f'Marking Object {inner_obj_id} deleted')
|
||||
to_delete.deleted = True
|
||||
to_delete.put()
|
||||
|
||||
# assume this is an actor
|
||||
# https://github.com/snarfed/bridgy-fed/issues/63
|
||||
logger.info(f'Deactivating Followers with src or dest = {obj_id}')
|
||||
followers = Follower.query(OR(Follower.src == obj_id,
|
||||
Follower.dest == obj_id)
|
||||
logger.info(f'Deactivating Followers with src or dest = {inner_obj_id}')
|
||||
followers = Follower.query(OR(Follower.src == inner_obj_id,
|
||||
Follower.dest == inner_obj_id)
|
||||
).fetch()
|
||||
for f in followers:
|
||||
f.status = 'inactive'
|
||||
activity_obj.status = 'complete'
|
||||
ndb.put_multi(followers + [activity_obj])
|
||||
obj.status = 'complete'
|
||||
ndb.put_multi(followers + [obj])
|
||||
return 'OK'
|
||||
|
||||
# fetch actor if necessary so we have name, profile photo, etc
|
||||
if actor and isinstance(actor, str):
|
||||
actor = activity['actor'] = activity_unwrapped['actor'] = \
|
||||
common.get_object(actor, user=user).as2
|
||||
actor = obj.as2['actor'] = common.get_object(actor, user=user).as2
|
||||
|
||||
# fetch object if necessary so we can render it in feeds
|
||||
inner_obj = activity_unwrapped.get('object')
|
||||
if type in FETCH_OBJECT_TYPES and isinstance(inner_obj, str):
|
||||
obj = Object.get_by_id(inner_obj) or common.get_object(inner_obj, user=user)
|
||||
obj_as2 = activity['object'] = activity_unwrapped['object'] = \
|
||||
obj.as2 if obj.as2 else as2.from_as1(obj.as1)
|
||||
if obj.type in FETCH_OBJECT_TYPES and inner_obj.keys() == set(['id']):
|
||||
inner_obj = obj.as2['object'] = common.get_object(inner_obj_id, user=user).as2
|
||||
|
||||
if type == 'Follow':
|
||||
resp = accept_follow(activity, activity_unwrapped, user)
|
||||
if obj.type == 'follow':
|
||||
resp = accept_follow(obj, user)
|
||||
|
||||
# send webmentions to each target
|
||||
activity_obj.as2 = activity_unwrapped
|
||||
common.send_webmentions(as2.to_as1(activity), activity_obj, proxy=True)
|
||||
common.send_webmentions(as2.to_as1(activity), obj, proxy=True)
|
||||
|
||||
# deliver original posts and reposts to followers
|
||||
if ((type == 'Create' and not activity.get('inReplyTo') and not obj_as2.get('inReplyTo'))
|
||||
or type == 'Announce'):
|
||||
if obj.type in ('share', 'create', 'post'):
|
||||
# check that this activity is public. only do this check for Creates,
|
||||
# not Like, Follow, or other activity types, since Mastodon doesn't
|
||||
# currently mark those as explicitly public.
|
||||
if not as2.is_public(activity_unwrapped):
|
||||
if not as1.is_public(obj.as1):
|
||||
logger.info('Dropping non-public activity')
|
||||
return ''
|
||||
return 'OK'
|
||||
|
||||
if actor:
|
||||
actor_id = actor.get('id')
|
||||
if actor_id:
|
||||
logger.info(f'Finding followers of {actor_id}')
|
||||
for f in Follower.query(Follower.dest == actor_id,
|
||||
projection=[Follower.src]):
|
||||
if f.src not in activity_obj.domains:
|
||||
activity_obj.domains.append(f.src)
|
||||
if activity_obj.domains and 'feed' not in activity_obj.labels:
|
||||
activity_obj.labels.append('feed')
|
||||
if actor and actor_id:
|
||||
logger.info(f'Delivering to followers of {actor_id}')
|
||||
for f in Follower.query(Follower.dest == actor_id,
|
||||
projection=[Follower.src]):
|
||||
if f.src not in obj.domains:
|
||||
obj.domains.append(f.src)
|
||||
if obj.domains and 'feed' not in obj.labels:
|
||||
obj.labels.append('feed')
|
||||
|
||||
if (activity_obj.as1.get('objectType') == 'activity'
|
||||
and 'activity' not in activity_obj.labels):
|
||||
activity_obj.labels.append('activity')
|
||||
if (obj.as1.get('objectType') == 'activity'
|
||||
and 'activity' not in obj.labels):
|
||||
obj.labels.append('activity')
|
||||
|
||||
activity_obj.put()
|
||||
obj.put()
|
||||
return 'OK'
|
||||
|
||||
|
||||
|
@ -287,21 +295,18 @@ def verify_signature(user):
|
|||
error('HTTP Signature verification failed', status=401)
|
||||
|
||||
|
||||
def accept_follow(follow, follow_unwrapped, user):
|
||||
def accept_follow(obj, user):
|
||||
"""Replies to an AP Follow request with an Accept request.
|
||||
|
||||
Args:
|
||||
follow: dict, AP Follow activity
|
||||
follow_unwrapped: dict, same, except with redirect URLs unwrapped
|
||||
obj: :class:`Object`
|
||||
user: :class:`User`
|
||||
"""
|
||||
logger.info('Replying to Follow with Accept')
|
||||
|
||||
followee = follow.get('object')
|
||||
followee_unwrapped = follow_unwrapped.get('object')
|
||||
followee_id = (followee_unwrapped.get('id')
|
||||
if isinstance(followee_unwrapped, dict) else followee_unwrapped)
|
||||
follower = follow.get('actor')
|
||||
followee = obj.as2.get('object')
|
||||
followee_id = followee.get('id') if isinstance(followee, dict) else followee
|
||||
follower = obj.as2.get('actor')
|
||||
if not followee or not followee_id or not follower:
|
||||
error(f'Follow activity requires object and actor. Got: {follow}')
|
||||
|
||||
|
@ -316,60 +321,32 @@ def accept_follow(follow, follow_unwrapped, user):
|
|||
# so, set a synthetic URL based on the follower's profile.
|
||||
# https://github.com/snarfed/bridgy-fed/issues/336
|
||||
follower_url = util.get_url(follower) or follower_id
|
||||
followee_url = util.get_url(followee_unwrapped) or followee_id
|
||||
follow_unwrapped.setdefault('url', f'{follower_url}#followed-{followee_url}')
|
||||
followee_url = util.get_url(followee) or followee_id
|
||||
obj.as2.setdefault('url', f'{follower_url}#followed-{followee_url}')
|
||||
|
||||
# store Follower
|
||||
follower = Follower.get_or_create(dest=user.key.id(), src=follower_id,
|
||||
last_follow=follow)
|
||||
follower.status = 'active'
|
||||
follower.put()
|
||||
follower_obj = Follower.get_or_create(dest=user.key.id(), src=follower_id,
|
||||
last_follow=obj.as2)
|
||||
follower_obj.status = 'active'
|
||||
follower_obj.put()
|
||||
|
||||
# send AP Accept
|
||||
followee_actor_url = host_url(user.key.id())
|
||||
accept = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': util.tag_uri(common.PRIMARY_DOMAIN,
|
||||
f'accept/{user.key.id()}/{follow.get("id")}'),
|
||||
f'accept/{user.key.id()}/{obj.key.id()}'),
|
||||
'type': 'Accept',
|
||||
'actor': followee,
|
||||
'actor': followee_actor_url,
|
||||
'object': {
|
||||
'type': 'Follow',
|
||||
'actor': follower_id,
|
||||
'object': followee,
|
||||
'object': followee_actor_url,
|
||||
}
|
||||
}
|
||||
return common.signed_post(inbox, data=accept, user=user)
|
||||
|
||||
|
||||
@ndb.transactional()
|
||||
def undo_follow(undo_unwrapped):
|
||||
"""Handles an AP Undo Follow request by deactivating the Follower entity.
|
||||
|
||||
Args:
|
||||
undo_unwrapped: dict, AP Undo activity with redirect URLs unwrapped
|
||||
"""
|
||||
logger.info('Undoing Follow')
|
||||
|
||||
follow = undo_unwrapped.get('object', {})
|
||||
follower = follow.get('actor')
|
||||
followee = follow.get('object')
|
||||
if isinstance(followee, dict):
|
||||
followee = followee.get('id') or util.get_url(followee)
|
||||
if not follower or not followee:
|
||||
error(f'Undo of Follow requires object with actor and object. Got: {follow}')
|
||||
|
||||
# deactivate Follower
|
||||
user_domain = util.domain_from_link(followee, minimize=False)
|
||||
follower_obj = Follower.get_by_id(Follower._id(dest=user_domain, src=follower))
|
||||
if follower_obj:
|
||||
follower_obj.status = 'inactive'
|
||||
follower_obj.put()
|
||||
else:
|
||||
logger.warning(f'No Follower found for {user_domain} {follower}')
|
||||
|
||||
# TODO send webmention with 410 of u-follow
|
||||
|
||||
|
||||
@app.get(f'/<regex("{common.DOMAIN_RE}"):domain>/<any(followers,following):collection>')
|
||||
@flask_util.cached(cache, CACHE_TIME)
|
||||
def follower_collection(domain, collection):
|
||||
|
|
|
@ -358,9 +358,9 @@ class ActivityPubTest(testutil.TestCase):
|
|||
self._test_inbox_create_obj('/inbox', *mocks)
|
||||
|
||||
def _test_inbox_create_obj(self, path, mock_head, mock_get, mock_post):
|
||||
Follower.get_or_create(ACTOR['id'], 'foo.com')
|
||||
Follower.get_or_create(NOTE['actor'], 'foo.com')
|
||||
Follower.get_or_create('http://other/actor', 'bar.com')
|
||||
Follower.get_or_create(ACTOR['id'], 'baz.com')
|
||||
Follower.get_or_create(NOTE['actor'], 'baz.com')
|
||||
|
||||
mock_head.return_value = requests_response(url='http://target')
|
||||
mock_get.return_value = self.as2_resp(ACTOR) # source actor
|
||||
|
@ -480,7 +480,7 @@ class ActivityPubTest(testutil.TestCase):
|
|||
self.assertEqual(200, got.status_code, got.get_data(as_text=True))
|
||||
|
||||
obj = Object.get_by_id(not_public['id'])
|
||||
self.assertEqual([], obj.labels)
|
||||
self.assertEqual(['activity'], obj.labels)
|
||||
self.assertEqual([], obj.domains)
|
||||
|
||||
self.assertIsNone(Object.get_by_id(not_public['object']['id']))
|
||||
|
@ -572,9 +572,10 @@ class ActivityPubTest(testutil.TestCase):
|
|||
def test_inbox_follow_accept_with_id(self, *mocks):
|
||||
self._test_inbox_follow_accept(FOLLOW_WRAPPED, ACCEPT, *mocks)
|
||||
|
||||
follow = copy.deepcopy(FOLLOW_WITH_ACTOR)
|
||||
follow['url'] = 'https://mastodon.social/users/swentel#followed-https://foo.com/'
|
||||
|
||||
follow = {
|
||||
**FOLLOW_WITH_ACTOR,
|
||||
'url': 'https://mastodon.social/users/swentel#followed-https://foo.com/',
|
||||
}
|
||||
self.assert_object('https://mastodon.social/6d1a',
|
||||
domains=['foo.com'],
|
||||
source_protocol='activitypub',
|
||||
|
@ -586,7 +587,7 @@ class ActivityPubTest(testutil.TestCase):
|
|||
object_ids=[FOLLOW['object']])
|
||||
|
||||
follower = Follower.query().get()
|
||||
self.assertEqual(FOLLOW_WRAPPED_WITH_ACTOR, follower.last_follow)
|
||||
self.assertEqual(follow, follower.last_follow)
|
||||
|
||||
def test_inbox_follow_accept_with_object(self, *mocks):
|
||||
wrapped_user = {
|
||||
|
@ -599,23 +600,18 @@ class ActivityPubTest(testutil.TestCase):
|
|||
}
|
||||
|
||||
follow = {
|
||||
**FOLLOW_WRAPPED,
|
||||
'object': wrapped_user,
|
||||
**FOLLOW,
|
||||
'object': unwrapped_user,
|
||||
}
|
||||
|
||||
accept = copy.deepcopy(ACCEPT)
|
||||
accept['actor'] = accept['object']['object'] = wrapped_user
|
||||
|
||||
self._test_inbox_follow_accept(follow, accept, *mocks)
|
||||
self._test_inbox_follow_accept(follow, ACCEPT, *mocks)
|
||||
|
||||
follower = Follower.query().get()
|
||||
follow['actor'] = ACTOR
|
||||
self.assertEqual(follow, follower.last_follow)
|
||||
|
||||
follow.update({
|
||||
'object': unwrapped_user,
|
||||
'actor': ACTOR,
|
||||
'url': 'https://mastodon.social/users/swentel#followed-https://foo.com/',
|
||||
})
|
||||
self.assertEqual(follow, follower.last_follow)
|
||||
self.assert_object('https://mastodon.social/6d1a',
|
||||
domains=['foo.com'],
|
||||
source_protocol='activitypub',
|
||||
|
@ -631,7 +627,7 @@ class ActivityPubTest(testutil.TestCase):
|
|||
mock_head.return_value = requests_response(url='https://foo.com/')
|
||||
mock_get.side_effect = [
|
||||
# source actor
|
||||
self.as2_resp(FOLLOW_WITH_ACTOR['actor']),
|
||||
self.as2_resp(ACTOR),
|
||||
WEBMENTION_DISCOVERY,
|
||||
]
|
||||
mock_post.return_value = requests_response()
|
||||
|
@ -679,7 +675,10 @@ class ActivityPubTest(testutil.TestCase):
|
|||
# check that the Follower doesn't have www
|
||||
follower = Follower.get_by_id(f'foo.com {ACTOR["id"]}')
|
||||
self.assertEqual('active', follower.status)
|
||||
self.assertEqual(FOLLOW_WRAPPED_WITH_ACTOR, follower.last_follow)
|
||||
self.assertEqual({
|
||||
**FOLLOW_WITH_ACTOR,
|
||||
'url': 'https://mastodon.social/users/swentel#followed-https://foo.com/',
|
||||
}, follower.last_follow)
|
||||
|
||||
def test_inbox_undo_follow(self, mock_head, mock_get, mock_post):
|
||||
mock_head.return_value = requests_response(url='https://foo.com/')
|
||||
|
|
|
@ -111,10 +111,11 @@ class Webmention(View):
|
|||
props['url'] = [self.source_url]
|
||||
|
||||
self.source_as1 = microformats2.json_to_object(self.source_mf2, fetch_mf2=True)
|
||||
inner_obj = as1.get_object(self.source_as1, 'object')
|
||||
type_label = ' '.join((
|
||||
self.source_as1.get('verb', ''),
|
||||
self.source_as1.get('objectType', ''),
|
||||
util.get_first(self.source_as1, 'object', {}).get('objectType', ''),
|
||||
inner_obj.get('objectType', '') if isinstance(inner_obj, dict) else inner_obj,
|
||||
))
|
||||
logger.info(f'Converted webmention to AS1: {type_label}: {json_dumps(self.source_as1, indent=2)}')
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue