diff --git a/activitypub.py b/activitypub.py index e62ddd9..e469d37 100644 --- a/activitypub.py +++ b/activitypub.py @@ -714,7 +714,7 @@ def postprocess_as2_actor(actor, wrap=True): @flask_util.cached(cache, CACHE_TIME) def actor(handle_or_id): """Serves a user's AS2 actor from the datastore.""" - cls = Protocol.for_request(fed=PROTOCOLS['web']) + cls = Protocol.for_request(fed='web') if not cls: error(f"Couldn't determine protocol", status=404) @@ -771,15 +771,17 @@ def actor(handle_or_id): } -# note that this path overlaps with the /ap/ actor route above, -# but doesn't collide because this is POST and that one is GET. +# note that this shared inbox path overlaps with the /ap/ actor +# route above, but doesn't collide because this is POST and that one is GET. @app.post('/ap/sharedInbox') -# TODO: protocol in subdomain +# source protocol in subdomain +@app.post(f'/ap//inbox') +# source protocol in path; primarily for backcompat @app.post(f'/ap///inbox') # special case Web users without /ap/web/ prefix, for backward compatibility -@app.post('/inbox') -@app.post(f'//inbox') -def inbox(protocol='web', id=None): +@app.post('/inbox', defaults={'protocol': 'web'}) +@app.post(f'//inbox', defaults={'protocol': 'web'}) +def inbox(protocol=None, id=None): """Handles ActivityPub inbox delivery.""" # parse and validate AS2 activity try: @@ -796,13 +798,12 @@ def inbox(protocol='web', id=None): # load receiving user obj_id = as1.get_object(redirect_unwrap(activity)).get('id') - to_proto = None - if protocol: - to_proto = PROTOCOLS[protocol] - elif type == 'Follow': + to_proto = PROTOCOLS[protocol] if protocol else Protocol.for_request(fed='web') + if not to_proto and type == 'Follow': to_proto = Protocol.for_id(obj_id) if to_proto: + logger.info(f'To protocol {to_proto.LABEL}') to_user_id = None if id: to_user_id = id @@ -860,7 +861,7 @@ def follower_collection(id, collection): * https://www.w3.org/TR/activitypub/#collections * https://www.w3.org/TR/activitystreams-core/#paging """ - protocol = Protocol.for_request(fed=PROTOCOLS['web']) + protocol = Protocol.for_request(fed='web') assert protocol g.user = protocol.get_by_id(id) if not g.user: @@ -912,7 +913,7 @@ def follower_collection(id, collection): # special case Web users without /ap/web/ prefix, for backward compatibility @app.get(f'//outbox') def outbox(id): - protocol = Protocol.for_request(fed=PROTOCOLS['web']) + protocol = Protocol.for_request(fed='web') assert protocol return { '@context': 'https://www.w3.org/ns/activitystreams', diff --git a/common.py b/common.py index 6cd561f..115abd6 100644 --- a/common.py +++ b/common.py @@ -36,24 +36,26 @@ PRIMARY_DOMAIN = 'fed.brid.gy' SUPERDOMAIN = '.brid.gy' # TODO: add a Flask route decorator version of util.canonicalize_domain, then # use it to canonicalize most UI routes from these to fed.brid.gy. -OTHER_DOMAINS = ( +PROTOCOL_DOMAINS = ( 'ap.brid.gy', 'atp.brid.gy', 'atproto.brid.gy', 'bluesky.brid.gy', 'bsky.brid.gy', - 'bridgy-federated.appspot.com', - 'bridgy-federated.uc.r.appspot.com', 'fa.brid.gy', 'nostr.brid.gy', 'web.brid.gy', ) +OTHER_DOMAINS = ( + 'bridgy-federated.appspot.com', + 'bridgy-federated.uc.r.appspot.com', +) LOCAL_DOMAINS = ( 'localhost', 'localhost:8080', 'my.dev.com:8080', ) -DOMAINS = (PRIMARY_DOMAIN,) + OTHER_DOMAINS + LOCAL_DOMAINS +DOMAINS = (PRIMARY_DOMAIN,) + PROTOCOL_DOMAINS + OTHER_DOMAINS + LOCAL_DOMAINS # TODO: unify with Bridgy's DOMAIN_BLOCKLIST = ( # https://github.com/snarfed/bridgy-fed/issues/348 @@ -96,6 +98,7 @@ def host_url(path_query=None): or (not DEBUG and request.host in LOCAL_DOMAINS)): base = f'https://{PRIMARY_DOMAIN}' + assert base return urllib.parse.urljoin(base, path_query) diff --git a/protocol.py b/protocol.py index b4aad2d..385c298 100644 --- a/protocol.py +++ b/protocol.py @@ -74,12 +74,13 @@ class Protocol: ...based on the request's hostname. Args: - fed: :class:`Protocol` subclass to return if the current request is on - fed.brid.gy + fed (str or Protocol): protocol to return if the current request is on + ``fed.brid.gy`` Returns: - :class:`Protocol` subclass, or None if the provided domain or request - hostname domain is not a subdomain of brid.gy or isn't a known protocol + Protocol subclass: ...or None if the provided domain or request + hostname domain is not a subdomain of ``brid.gy` or isn't a known + protocol """ return Protocol.for_bridgy_subdomain(request.host, fed=fed) @@ -89,19 +90,20 @@ class Protocol: Args: domain_or_url: str - fed: :class:`Protocol` subclass to return if the domain_or_url is on - fed.brid.gy + fed (str or Protocol): protocol to return if the current request is on + ``fed.brid.gy`` Returns: - :class:`Protocol` subclass, or None if the request hostname is not a - subdomain of brid.gy or isn't a known protocol + Protocol subclass: ...or None if the provided domain or request + hostname domain is not a subdomain of ``brid.gy` or isn't a known + protocol """ domain = (util.domain_from_link(domain_or_url, minimize=False) if util.is_web(domain_or_url) else domain_or_url) if domain == common.PRIMARY_DOMAIN or domain in common.LOCAL_DOMAINS: - return fed + return PROTOCOLS[fed] if isinstance(fed, str) else fed elif domain and domain.endswith(common.SUPERDOMAIN): label = domain.removesuffix(common.SUPERDOMAIN) return PROTOCOLS.get(label) diff --git a/tests/test_activitypub.py b/tests/test_activitypub.py index 5eec280..fc4c058 100644 --- a/tests/test_activitypub.py +++ b/tests/test_activitypub.py @@ -13,7 +13,8 @@ from google.cloud import ndb from granary import as2, microformats2 from httpsig import HeaderSigner from oauth_dropins.webutil.testutil import requests_response -from oauth_dropins.webutil.util import json_dumps, json_loads +from oauth_dropins.webutil.util import domain_from_link, json_dumps, json_loads +from oauth_dropins.webutil import util import requests from urllib3.exceptions import ReadTimeoutError from werkzeug.exceptions import BadGateway @@ -232,11 +233,11 @@ ACCEPT = { } UNDO_FOLLOW_WRAPPED = { - '@context': 'https://www.w3.org/ns/activitystreams', - 'id': 'https://mas.to/6d1b', - 'type': 'Undo', - 'actor': 'https://mas.to/users/swentel', - 'object': FOLLOW_WRAPPED, + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': 'https://mas.to/6d1b', + 'type': 'Undo', + 'actor': 'https://mas.to/users/swentel', + 'object': FOLLOW_WRAPPED, } DELETE = { @@ -318,12 +319,12 @@ class ActivityPubTest(TestCase): props.setdefault('delivered_protocol', 'web') return super().assert_object(id, **props) - def sign(self, path, body): + def sign(self, path, body, host=None): """Constructs HTTP Signature, returns headers.""" digest = b64encode(sha256(body.encode()).digest()).decode() headers = { 'Date': 'Sun, 02 Jan 2022 03:04:05 GMT', - 'Host': 'localhost', + 'Host': host or 'localhost', 'Content-Type': as2.CONTENT_TYPE, 'Digest': f'SHA-256={digest}', } @@ -332,10 +333,13 @@ class ActivityPubTest(TestCase): headers=('Date', 'Host', 'Digest', '(request-target)')) return hs.sign(headers, method='POST', path=path) - def post(self, path, json=None): + def post(self, path, json=None, base_url=None, **kwargs): """Wrapper around self.client.post that adds signature.""" body = json_dumps(json) - return self.client.post(path, data=body, headers=self.sign(path, body)) + host = domain_from_link(base_url) if base_url else None + headers = self.sign(path, body, host=host) + return self.client.post(path, data=body, headers=headers, + base_url=base_url, **kwargs) def test_actor_fake(self, *_): self.make_user('fake:user', cls=Fake) @@ -541,6 +545,24 @@ class ActivityPubTest(TestCase): }, ) + def test_inbox_reply_protocol_subdomain(self, reply, *_): + Fake.fetchable['fake:post'] = as2.to_as1({ + **NOTE_OBJECT, + 'id': 'fake:post', + }) + reply = { + **REPLY_OBJECT, + 'id': 'fake:my-reply', + 'inReplyTo': 'fake:post', + } + got = self.post('/ap/fake:user/inbox', json=reply, + base_url='https://fa.brid.gy/') + self.assertEqual(200, got.status_code) + + [(obj, target)] = Fake.sent + self.assertEqual('fake:my-reply#bridgy-fed-create', obj.our_as1['id']) + self.assertEqual('fake:post:target', target) + def test_inbox_reply_to_self_domain(self, *mocks): self._test_inbox_ignore_reply_to('http://localhost/mas.to', *mocks) diff --git a/tests/test_common.py b/tests/test_common.py index d1226da..cc3c5c5 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -101,3 +101,6 @@ class CommonTest(TestCase): with app.test_request_context(base_url='http://bridgy-federated.uc.r.appspot.com'): self.assertEqual('https://fed.brid.gy/asdf', common.host_url('asdf')) + + with app.test_request_context(base_url='https://atproto.brid.gy', path='/foo'): + self.assertEqual('https://atproto.brid.gy/asdf', common.host_url('asdf')) diff --git a/tests/test_convert.py b/tests/test_convert.py index 730bf30..1e98667 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -22,6 +22,11 @@ COMMENT_AS2 = { 'name': 'A ☕ reply', 'inReplyTo': 'https://fake.com/123', } +COMMENT_AS2_WEB = { + **COMMENT_AS2, + 'id': 'https://web.brid.gy/r/tag:fake.com:123456', + 'url': 'https://web.brid.gy/r/https://fake.com/123456', +} HTML = """\ @@ -248,7 +253,7 @@ A ☕ reply resp = self.client.get(f'/convert/ap/{url}', base_url='https://web.brid.gy/') self.assertEqual(200, resp.status_code) - self.assert_equals(COMMENT_AS2, resp.json, ignore=['to']) + self.assert_equals(COMMENT_AS2_WEB, resp.json, ignore=['to']) @patch('requests.get') def test_web_to_activitypub_fetch(self, mock_get): @@ -261,7 +266,7 @@ A ☕ reply resp = self.client.get(f'/convert/ap/{url}', base_url='https://web.brid.gy/') self.assertEqual(200, resp.status_code) - self.assert_equals(COMMENT_AS2, resp.json, ignore=['to']) + self.assert_equals(COMMENT_AS2_WEB, resp.json, ignore=['to']) def test_web_to_activitypub_no_user(self): resp = self.client.get(f'/convert/ap/http://nope.com/post', @@ -276,7 +281,7 @@ A ☕ reply resp = self.client.get(f'/convert/ap/http://user.com/a%23b', base_url='https://web.brid.gy/') self.assertEqual(200, resp.status_code) - self.assert_equals(COMMENT_AS2, resp.json, ignore=['to']) + self.assert_equals(COMMENT_AS2_WEB, resp.json, ignore=['to']) def test_fed_subdomain(self): url = 'https://user.com/post' diff --git a/tests/test_protocol.py b/tests/test_protocol.py index d6a3248..882273f 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -74,6 +74,8 @@ class ProtocolTest(TestCase): with self.subTest(url=url, expected=expected): self.assertEqual(expected, Protocol.for_bridgy_subdomain(url, fed=Fake)) + self.assertEqual(expected, + Protocol.for_bridgy_subdomain(url, fed='fake')) with app.test_request_context('/foo', base_url=url): self.assertEqual(expected, Protocol.for_request(fed=Fake)) diff --git a/tests/test_webfinger.py b/tests/test_webfinger.py index cf011d3..7bf24e2 100644 --- a/tests/test_webfinger.py +++ b/tests/test_webfinger.py @@ -49,7 +49,7 @@ WEBFINGER = { 'href': 'https://web.brid.gy/ap/sharedInbox', }, { 'rel': 'http://ostatus.org/schema/1.0/subscribe', - 'template': 'http://localhost/web/user.com?url={uri}', + 'template': 'https://fed.brid.gy/web/user.com?url={uri}', }], } WEBFINGER_NO_HCARD = { @@ -77,7 +77,7 @@ WEBFINGER_NO_HCARD = { 'href': 'https://web.brid.gy/ap/sharedInbox', }, { 'rel': 'http://ostatus.org/schema/1.0/subscribe', - 'template': 'http://localhost/web/user.com?url={uri}', + 'template': 'https://fed.brid.gy/web/user.com?url={uri}', }], } WEBFINGER_FAKE = { @@ -101,7 +101,7 @@ WEBFINGER_FAKE = { 'href': 'https://web.brid.gy/ap/sharedInbox', }, { 'rel': 'http://ostatus.org/schema/1.0/subscribe', - 'template': 'http://localhost/fa/fake:user?url={uri}', + 'template': 'https://fed.brid.gy/fa/fake:handle:user?url={uri}', }], } WEBFINGER_FAKE_FA_BRID_GY = copy.deepcopy(WEBFINGER_FAKE) @@ -109,7 +109,7 @@ for link in WEBFINGER_FAKE_FA_BRID_GY['links']: if 'href' in link: link['href'] = link['href'].replace('http://localhost/ap/fa', 'https://fa.brid.gy/ap') WEBFINGER_FAKE_FA_BRID_GY['links'][3]['href'] = 'https://fa.brid.gy/ap/sharedInbox' -WEBFINGER_FAKE_FA_BRID_GY['links'][4]['template'] = 'https://fed.brid.gy/fa/fake:user?url={uri}' +WEBFINGER_FAKE_FA_BRID_GY['links'][4]['template'] = 'https://fed.brid.gy/fa/fake:handle:user?url={uri}' class HostMetaTest(TestCase): diff --git a/webfinger.py b/webfinger.py index 3b9e05b..fed7f63 100644 --- a/webfinger.py +++ b/webfinger.py @@ -15,7 +15,6 @@ from oauth_dropins.webutil.util import json_dumps, json_loads import common from flask_app import app, cache from protocol import Protocol -from web import Web SUBSCRIBE_LINK_REL = 'http://ostatus.org/schema/1.0/subscribe' @@ -50,7 +49,7 @@ class Webfinger(flask_util.XrdOrJrd): cls = None try: user, id = util.parse_acct_uri(resource) - cls = Protocol.for_bridgy_subdomain(id, fed=Web) + cls = Protocol.for_bridgy_subdomain(id, fed='web') if cls: id = user allow_indirect = True @@ -58,7 +57,7 @@ class Webfinger(flask_util.XrdOrJrd): id = urllib.parse.urlparse(resource).netloc or resource if not cls: - cls = Protocol.for_request(fed=Web) + cls = Protocol.for_request(fed='web') if cls.owns_id(id) is False: logger.info(f'{id} is not a {cls.LABEL} id') @@ -142,11 +141,13 @@ class Webfinger(flask_util.XrdOrJrd): # https://github.com/snarfed/bridgy-fed/issues/60#issuecomment-1325589750 { 'rel': 'http://ostatus.org/schema/1.0/subscribe', + # always use fed.brid.gy for UI pages, not protocol subdomain # TODO: switch to: # 'template': common.host_url(g.user.user_page_path('?url={uri}')), # the problem is that user_page_path() uses handle_or_id, which uses # custom username instead of domain, which may not be unique - 'template': common.host_url(f'{cls.ABBREV}/{id}?url={{uri}}'), + 'template': f'https://{common.PRIMARY_DOMAIN}' + + g.user.user_page_path('?url={uri}'), }] })