From c12bb6db6dbde65173fffa378b913ca3e6039eb9 Mon Sep 17 00:00:00 2001 From: Ryan Barrett Date: Tue, 14 Feb 2023 08:25:41 -0800 Subject: [PATCH] serve AS2 /[domain] actors from datastore instead of refetching h-card for #392. not pretty, but gets the job done. more code cleanup needed eventually. --- activitypub.py | 33 +++++++++++--- common.py | 5 +-- tests/test_activitypub.py | 91 +++------------------------------------ tests/test_models.py | 84 ++++++++++++++++++++++++++++++++---- tests/test_xrpc_graph.py | 2 + 5 files changed, 113 insertions(+), 102 deletions(-) diff --git a/activitypub.py b/activitypub.py index 62c48b2..c2137f4 100644 --- a/activitypub.py +++ b/activitypub.py @@ -16,7 +16,7 @@ from oauth_dropins.webutil.util import json_dumps, json_loads from app import app, cache import common -from common import CACHE_TIME, redirect_unwrap, redirect_wrap, TLD_BLOCKLIST +from common import CACHE_TIME, host_url, redirect_unwrap, redirect_wrap, TLD_BLOCKLIST from models import Follower, Object, Target, User logger = logging.getLogger(__name__) @@ -46,9 +46,9 @@ seen_ids_lock = threading.Lock() @app.get(f'/') -@flask_util.cached(cache, CACHE_TIME, http_5xx=True) +@flask_util.cached(cache, CACHE_TIME) def actor(domain): - """Fetches a domain's h-card and converts to AS2 actor.""" + """Serves a user's AS2 actor from the datastore.""" tld = domain.split('.')[-1] if tld in TLD_BLOCKLIST: error('', status=404) @@ -56,12 +56,33 @@ def actor(domain): user = User.get_by_id(domain) if not user: return f'User {domain} not found', 404 + elif not user.actor_as2: + return f'User {domain} not fully set up', 404 - _, _, actor = common.actor(user) - return (actor, { + # TODO: unify with common.actor() + actor = { + **common.postprocess_as2(json_loads(user.actor_as2), user=user), + 'id': host_url(domain), + # This has to be the domain for Mastodon etc interop! It seems like it + # should be the custom username from the acct: u-url in their h-card, + # but that breaks Mastodon's Webfinger discovery. Background: + # https://github.com/snarfed/bridgy-fed/issues/302#issuecomment-1324305460 + # https://github.com/snarfed/bridgy-fed/issues/77 + 'preferredUsername': domain, + 'inbox': host_url(f'{domain}/inbox'), + 'outbox': host_url(f'{domain}/outbox'), + 'following': host_url(f'{domain}/following'), + 'followers': host_url(f'{domain}/followers'), + 'endpoints': { + 'sharedInbox': host_url('inbox'), + }, + } + + logger.info(f'Returning: {json_dumps(actor, indent=2)}') + return actor, { 'Content-Type': as2.CONTENT_TYPE, 'Access-Control-Allow-Origin': '*', - }) + } @app.post('/inbox') diff --git a/common.py b/common.py index a6be1b4..a8313d3 100644 --- a/common.py +++ b/common.py @@ -596,10 +596,6 @@ def redirect_unwrap(val): def actor(user): """Fetches a home page, converts its representative h-card to AS2 actor. - Creates a User for the given domain if one doesn't already exist. - - TODO: unify with webfinger.Actor - Args: user: :class:`User` @@ -620,6 +616,7 @@ def actor(user): actor_as1 = microformats2.json_to_object(hcard, rel_urls=mf2.get('rel-urls')) actor_as2 = postprocess_as2(as2.from_as1(actor_as1), user=user) + # TODO: unify with activitypub.actor() actor_as2.update({ 'id': host_url(domain), # This has to be the domain for Mastodon etc interop! It seems like it diff --git a/tests/test_activitypub.py b/tests/test_activitypub.py index a61a051..9a0ca5c 100644 --- a/tests/test_activitypub.py +++ b/tests/test_activitypub.py @@ -21,6 +21,8 @@ ACTOR = { 'id': 'https://mastodon.social/users/swentel', 'type': 'Person', 'inbox': 'http://follower/inbox', + 'name': 'Mrs. ☕ Foo', + 'icon': {'type': 'Image', 'url': 'https://foo.com/me.jpg'}, } REPLY_OBJECT = { '@context': 'https://www.w3.org/ns/activitystreams', @@ -190,18 +192,12 @@ class ActivityPubTest(testutil.TestCase): def setUp(self): super().setUp() - self.user = User.get_or_create('foo.com') + self.user = User.get_or_create('foo.com', has_hcard=True, + actor_as2=json_dumps(ACTOR)) activitypub.seen_ids.clear() - def test_actor(self, _, mock_get, __): - mock_get.return_value = requests_response(""" - -Mrs. ☕ Foo - -""", url='https://foo.com/', content_type=common.CONTENT_TYPE_HTML) - + def test_actor(self, *_): got = self.client.get('/foo.com') - self.assert_req(mock_get, 'https://foo.com/') self.assertEqual(200, got.status_code) type = got.headers['Content-Type'] self.assertTrue(type.startswith(as2.CONTENT_TYPE), type) @@ -215,12 +211,8 @@ class ActivityPubTest(testutil.TestCase): 'summary': '', 'preferredUsername': 'foo.com', 'id': 'http://localhost/foo.com', - 'url': 'http://localhost/r/https://foo.com/about-me', - 'attachment': [{ - 'type': 'PropertyValue', - 'name': 'Mrs. ☕ Foo', - 'value': 'foo.com/about-me', - }], + 'url': 'http://localhost/r/https://foo.com/', + 'icon': {'type': 'Image', 'url': 'https://foo.com/me.jpg'}, 'inbox': 'http://localhost/foo.com/inbox', 'outbox': 'http://localhost/foo.com/outbox', 'following': 'http://localhost/foo.com/following', @@ -235,79 +227,10 @@ class ActivityPubTest(testutil.TestCase): }, }, got.json) - def test_actor_rel_me_links(self, _, mock_get, __): - mock_get.return_value = requests_response(""" - - - -""", url='https://foo.com/', content_type=common.CONTENT_TYPE_HTML) - - got = self.client.get('/foo.com') - self.assertEqual(200, got.status_code) - self.assertEqual([{ - 'type': 'PropertyValue', - 'name': 'Mrs. ☕ Foo', - 'value': 'foo.com/about-me', - }, { - 'type': 'PropertyValue', - 'name': 'Web site', - 'value': 'foo.com', - }, { - 'type': 'PropertyValue', - 'name': 'one text', - 'value': 'one', - }, { - 'type': 'PropertyValue', - 'name': 'two title', - 'value': 'two', - }], got.json['attachment']) - - def test_actor_no_hcard(self, _, mock_get, __): - mock_get.return_value = requests_response(""" - -
-

foo bar

-
- -""") - - got = self.client.get('/foo.com') - self.assert_req(mock_get, 'https://foo.com/') - self.assertEqual(400, got.status_code) - self.assertIn('representative h-card', got.get_data(as_text=True)) - - def test_actor_override_preferredUsername(self, _, mock_get, __): - mock_get.return_value = requests_response(""" - - - Nick - - -""", url='https://foo.com/', content_type=common.CONTENT_TYPE_HTML) - - got = self.client.get('/foo.com') - self.assertEqual(200, got.status_code) - self.assertEqual('foo.com', got.json['preferredUsername']) - def test_actor_blocked_tld(self, _, __, ___): got = self.client.get('/foo.json') self.assertEqual(404, got.status_code) - def test_actor_bad_domain(self, _, mock_get, ___): - # https://console.cloud.google.com/errors/detail/CKGv-b6impW3Jg;time=P30D?project=bridgy-federated - mock_get.side_effect = [ - ValueError('Invalid IPv6 URL'), - ] - got = self.client.get('/foo.com') - self.assertEqual(400, got.status_code) - def test_actor_no_user(self, *mocks): got = self.client.get('/nope.com') self.assertEqual(404, got.status_code) diff --git a/tests/test_models.py b/tests/test_models.py index 12ad449..a6be61a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,6 +2,7 @@ """Unit tests for models.py.""" from unittest import mock +from flask import get_flashed_messages from granary import as2 from oauth_dropins.webutil.testutil import requests_response from oauth_dropins.webutil.util import json_dumps, json_loads @@ -18,6 +19,10 @@ class UserTest(testutil.TestCase): super(UserTest, self).setUp() self.user = User.get_or_create('y.z') + self.full_redir = requests_response( + status=302, + redirected_url='http://localhost/.well-known/webfinger?resource=acct:y.z@y.z') + def test_get_or_create(self): assert self.user.mod assert self.user.public_exponent @@ -116,23 +121,31 @@ Current vs expected:
- http://localhost/.well-known/webfinger
 http://this/404s
   returned HTTP 404
""") + @mock.patch('requests.get') + def test_verify_no_hcard(self, mock_get): + mock_get.side_effect = [ + self.full_redir, + requests_response(""" + +
+

foo bar

+
+ +"""), + ] + self._test_verify(True, False, None) + @mock.patch('requests.get') def test_verify_non_representative_hcard(self, mock_get): - full_redir = requests_response( - status=302, - redirected_url='http://localhost/.well-known/webfinger?resource=acct:y.z@y.z') bad_hcard = requests_response( 'acct:me@y.z', url='https://y.z/', ) - mock_get.side_effect = [full_redir, bad_hcard] + mock_get.side_effect = [self.full_redir, bad_hcard] self._test_verify(True, False, None) @mock.patch('requests.get') def test_verify_both_work(self, mock_get): - full_redir = requests_response( - status=302, - redirected_url='http://localhost/.well-known/webfinger?resource=acct:y.z@y.z') hcard = requests_response(""" me @@ -140,7 +153,7 @@ http://this/404s """, url='https://y.z/', ) - mock_get.side_effect = [full_redir, hcard] + mock_get.side_effect = [self.full_redir, hcard] self._test_verify(True, True, { 'type': 'Person', 'name': 'me', @@ -166,6 +179,61 @@ http://this/404s self.assertEqual(root_user.key, www_user.key.get().use_instead) self.assertEqual(root_user.key, User.get_or_create('www.y.z').key) + @mock.patch('requests.get') + def test_verify_actor_rel_me_links(self, mock_get): + mock_get.side_effect = [ + self.full_redir, + requests_response(""" + + + +""", url='https://y.z/'), + ] + self._test_verify(True, True, { + 'attachment': [{ + 'type': 'PropertyValue', + 'name': 'Mrs. ☕ Foo', + 'value': 'y.z/about-me', + }, { + 'type': 'PropertyValue', + 'name': 'Web site', + 'value': 'y.z', + }, { + 'type': 'PropertyValue', + 'name': 'one text', + 'value': 'one', + }, { + 'type': 'PropertyValue', + 'name': 'two title', + 'value': 'two', + }]}) + + @mock.patch('requests.get') + def test_verify_override_preferredUsername(self, mock_get): + mock_get.side_effect = [ + self.full_redir, + requests_response(""" + + + Nick + + +""", url='https://y.z/'), + ] + self._test_verify(True, True, { + # 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 + 'preferredUsername': 'y.z', + }) + def test_homepage(self): self.assertEqual('https://y.z/', self.user.homepage) diff --git a/tests/test_xrpc_graph.py b/tests/test_xrpc_graph.py index ead6137..cda3581 100644 --- a/tests/test_xrpc_graph.py +++ b/tests/test_xrpc_graph.py @@ -32,6 +32,8 @@ FOLLOWERS_BSKY = [{ '$type': 'app.bsky.graph.getFollowers#follower', 'did': 'did:web:mastodon.social:users:swentel', 'handle': 'mastodon.social/users/swentel', + 'displayName': 'Mrs. ☕ Foo', + 'avatar': 'https://foo.com/me.jpg', 'declaration': ACTOR_DECLARATION, 'indexedAt': '2022-01-02T03:04:05+00:00', }]