kopia lustrzana https://github.com/snarfed/bridgy-fed
create AP users on inbox delivery for an indirect or nonexistent Web user
for #512circle-datastore-transactions
rodzic
086c6d032c
commit
28eabd07a3
|
@ -48,7 +48,10 @@ def default_signature_user():
|
||||||
|
|
||||||
|
|
||||||
class ActivityPub(User, Protocol):
|
class ActivityPub(User, Protocol):
|
||||||
"""ActivityPub protocol class."""
|
"""ActivityPub protocol class.
|
||||||
|
|
||||||
|
Key id is AP/AS2 actor id URL. (*Not* fediverse/WebFinger @-@ handle!)
|
||||||
|
"""
|
||||||
LABEL = 'activitypub'
|
LABEL = 'activitypub'
|
||||||
|
|
||||||
@classmethod
|
@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)
|
error(f"Couldn't parse body as non-empty JSON mapping: {body}", exc_info=True)
|
||||||
|
|
||||||
type = activity.get('type')
|
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)}')
|
logger.info(f'Got {type} from {actor_id}: {json_dumps(activity, indent=2)}')
|
||||||
|
|
||||||
# load user
|
# load user
|
||||||
|
# TODO(#512) parameterize on protocol, move to Protocol
|
||||||
if protocol and domain:
|
if protocol and domain:
|
||||||
g.user = PROTOCOLS[protocol].get_by_id(domain)
|
g.user = PROTOCOLS[protocol].get_by_id(domain) # receiving user
|
||||||
if not g.user:
|
if (not g.user or not g.user.direct) and actor_id:
|
||||||
error(f'{protocol} user {domain} not found', status=404)
|
# 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)
|
ActivityPub.verify_signature(activity)
|
||||||
|
|
||||||
|
|
10
models.py
10
models.py
|
@ -79,9 +79,9 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
||||||
property: p256_key, PEM encoded
|
property: p256_key, PEM encoded
|
||||||
https://atproto.com/guides/overview#account-portability
|
https://atproto.com/guides/overview#account-portability
|
||||||
"""
|
"""
|
||||||
mod = ndb.StringProperty(required=True)
|
mod = ndb.StringProperty()
|
||||||
public_exponent = ndb.StringProperty(required=True)
|
public_exponent = ndb.StringProperty()
|
||||||
private_exponent = ndb.StringProperty(required=True)
|
private_exponent = ndb.StringProperty()
|
||||||
p256_key = ndb.StringProperty()
|
p256_key = ndb.StringProperty()
|
||||||
has_redirects = ndb.BooleanProperty()
|
has_redirects = ndb.BooleanProperty()
|
||||||
redirects_error = ndb.TextProperty()
|
redirects_error = ndb.TextProperty()
|
||||||
|
@ -129,7 +129,8 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
||||||
if user:
|
if user:
|
||||||
# override direct if it's set
|
# override direct if it's set
|
||||||
direct = kwargs.get('direct')
|
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.direct = direct
|
||||||
user.put()
|
user.put()
|
||||||
return user
|
return user
|
||||||
|
@ -153,6 +154,7 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
|
||||||
kwargs['p256_key'] = key.export_key(format='PEM')
|
kwargs['p256_key'] = key.export_key(format='PEM')
|
||||||
|
|
||||||
user = cls(id=id, **kwargs)
|
user = cls(id=id, **kwargs)
|
||||||
|
logger.info(f'Created new {user}')
|
||||||
user.put()
|
user.put()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
|
@ -109,15 +109,21 @@ LIKE = {
|
||||||
}
|
}
|
||||||
LIKE_WRAPPED = copy.deepcopy(LIKE)
|
LIKE_WRAPPED = copy.deepcopy(LIKE)
|
||||||
LIKE_WRAPPED['object'] = 'http://localhost/r/https://user.com/post'
|
LIKE_WRAPPED['object'] = 'http://localhost/r/https://user.com/post'
|
||||||
LIKE_WITH_ACTOR = copy.deepcopy(LIKE)
|
LIKE_ACTOR = {
|
||||||
# TODO: use ACTOR instead
|
|
||||||
LIKE_WITH_ACTOR['actor'] = {
|
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
'id': 'https://user.com/actor',
|
'id': 'https://user.com/actor',
|
||||||
'type': 'Person',
|
'type': 'Person',
|
||||||
'name': 'Ms. Actor',
|
'name': 'Ms. Actor',
|
||||||
'preferredUsername': 'msactor',
|
'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,
|
# repost, should be delivered to followers if object is a fediverse post,
|
||||||
|
@ -230,8 +236,7 @@ class ActivityPubTest(TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.user = self.make_user('user.com',
|
self.user = self.make_user('user.com', has_hcard=True, actor_as2=ACTOR)
|
||||||
has_hcard=True, actor_as2=ACTOR)
|
|
||||||
with self.request_context:
|
with self.request_context:
|
||||||
self.key_id_obj = Object(id='http://my/key/id', as2={
|
self.key_id_obj = Object(id='http://my/key/id', as2={
|
||||||
**ACTOR,
|
**ACTOR,
|
||||||
|
@ -323,9 +328,23 @@ class ActivityPubTest(TestCase):
|
||||||
got = self.client.get('/nope.com')
|
got = self.client.get('/nope.com')
|
||||||
self.assertEqual(404, got.status_code)
|
self.assertEqual(404, got.status_code)
|
||||||
|
|
||||||
def test_individual_inbox_no_user(self, *mocks):
|
def test_individual_inbox_no_user(self, mock_head, mock_get, mock_post):
|
||||||
got = self.post('/nope.com/inbox', json=REPLY)
|
self.user.key.delete()
|
||||||
self.assertEqual(404, got.status_code)
|
|
||||||
|
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, *_):
|
def test_inbox_activity_without_id(self, *_):
|
||||||
note = copy.deepcopy(NOTE)
|
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):
|
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_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()
|
mock_post.return_value = requests_response()
|
||||||
|
|
||||||
got = self.post('/user.com/inbox', json=reply)
|
got = self.post('/user.com/inbox', json=reply)
|
||||||
|
@ -640,6 +661,17 @@ class ActivityPubTest(TestCase):
|
||||||
labels=['notification', 'activity'],
|
labels=['notification', 'activity'],
|
||||||
object_ids=[LIKE['object']])
|
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):
|
def test_inbox_follow_accept_with_id(self, *mocks):
|
||||||
self._test_inbox_follow_accept(FOLLOW_WRAPPED, ACCEPT, *mocks)
|
self._test_inbox_follow_accept(FOLLOW_WRAPPED, ACCEPT, *mocks)
|
||||||
|
|
||||||
|
|
|
@ -86,7 +86,7 @@ with ndb_client.context():
|
||||||
models.reset_protocol_properties()
|
models.reset_protocol_properties()
|
||||||
|
|
||||||
import app
|
import app
|
||||||
import activitypub
|
from activitypub import ActivityPub, CONNEG_HEADERS_AS2_HTML
|
||||||
import common
|
import common
|
||||||
from web import Web
|
from web import Web
|
||||||
from flask_app import app, cache, init_globals
|
from flask_app import app, cache, init_globals
|
||||||
|
@ -184,7 +184,7 @@ class TestCase(unittest.TestCase, testutil.Asserts):
|
||||||
return f'com.example.record/{tid}'
|
return f'com.example.record/{tid}'
|
||||||
|
|
||||||
def get_as2(self, *args, **kwargs):
|
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)
|
return self.client.get(*args, **kwargs)
|
||||||
|
|
||||||
def req(self, url, **kwargs):
|
def req(self, url, **kwargs):
|
||||||
|
@ -202,7 +202,7 @@ class TestCase(unittest.TestCase, testutil.Asserts):
|
||||||
'Host': util.domain_from_link(url, minimize=False),
|
'Host': util.domain_from_link(url, minimize=False),
|
||||||
'Content-Type': 'application/activity+json',
|
'Content-Type': 'application/activity+json',
|
||||||
'Digest': ANY,
|
'Digest': ANY,
|
||||||
**activitypub.CONNEG_HEADERS_AS2_HTML,
|
**CONNEG_HEADERS_AS2_HTML,
|
||||||
**kwargs.pop('headers', {}),
|
**kwargs.pop('headers', {}),
|
||||||
}
|
}
|
||||||
return self.req(url, data=None, auth=ANY, headers=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',
|
ignore=['as1', 'created', 'expire',
|
||||||
'object_ids', 'type', 'updated'])
|
'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):
|
def assert_equals(self, expected, actual, msg=None, ignore=(), **kwargs):
|
||||||
return super().assert_equals(
|
return super().assert_equals(
|
||||||
expected, actual, msg=msg, ignore=tuple(ignore) + ('@context',), **kwargs)
|
expected, actual, msg=msg, ignore=tuple(ignore) + ('@context',), **kwargs)
|
||||||
|
|
Ładowanie…
Reference in New Issue