diff --git a/activitypub.py b/activitypub.py index 067e5d3..2d9cc3d 100644 --- a/activitypub.py +++ b/activitypub.py @@ -27,7 +27,7 @@ from common import ( TLD_BLOCKLIST, ) from models import Follower, Object, PROTOCOLS, Target, User -import protocol +from protocol import Protocol import web logger = logging.getLogger(__name__) @@ -47,7 +47,7 @@ def default_signature_user(): return _DEFAULT_SIGNATURE_USER -class ActivityPub(User, protocol.Protocol): +class ActivityPub(User, Protocol): """ActivityPub protocol class.""" LABEL = 'activitypub' @@ -58,7 +58,7 @@ class ActivityPub(User, protocol.Protocol): target = getattr(obj, 'target_as2', None) activity = obj.as2 or postprocess_as2(as2.from_as1(obj.as1), target=target) - activity['actor'] = actor_id(g.user) + activity['actor'] = g.user.ap_actor() return signed_post(url, log_data=True, data=activity) # TODO: return bool or otherwise unify return value with others @@ -214,45 +214,6 @@ class ActivityPub(User, protocol.Protocol): error('HTTP Signature verification failed', status=401) -def address(user): - """Returns a user's ActivityPub address, eg '@me@foo.com'. - - Args: - user: :class:`User` - - Returns: - str - """ - if user.direct: - return f'@{user.username()}@{user.key.id()}' - else: - return f'@{user.key.id()}@{request.host}' - - -def actor_id(user, rest=None): - """Returns a user's AS2 actor id. - - Example: 'https://fed.brid.gy/ap/bluesky/foo.com' - - Args: - user: :class:`User` - rest: str, optional, added as path suffix - - Returns: - str - """ - if user.direct or rest: - # special case Web users to skip /ap/web/ prefix, for backward compatibility - url = common.host_url(user.key.id() if user.LABEL == 'web' - else f'/ap{user.user_page_path()}') - if rest: - url += f'/{rest}' - return url - # TODO(#512): drop once we fetch site if web user doesn't already exist - else: - return redirect_wrap(user.homepage) - - def signed_get(url, **kwargs): return signed_request(util.requests_get, url, **kwargs) @@ -306,7 +267,7 @@ def signed_request(fn, url, data=None, log_data=True, headers=None, **kwargs): # implementations require, eg Peertube. # https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12#section-2.3 # https://github.com/snarfed/bridgy-fed/issues/40 - auth = HTTPSignatureAuth(secret=user.private_pem(), key_id=actor_id(user), + auth = HTTPSignatureAuth(secret=user.private_pem(), key_id=user.ap_actor(), algorithm='rsa-sha256', sign_header='signature', headers=HTTP_SIG_HEADERS) @@ -410,13 +371,13 @@ def postprocess_as2(activity, target=None, wrap=True): elif not id: obj['id'] = util.get_first(obj, 'url') or target_id elif g.user and g.user.is_homepage(id): - obj['id'] = actor_id(g.user) + obj['id'] = g.user.ap_actor() elif g.external_user: obj['id'] = redirect_wrap(g.external_user) # for Accepts if g.user and g.user.is_homepage(obj.get('object')): - obj['object'] = actor_id(g.user) + obj['object'] = g.user.ap_actor() elif g.external_user and g.external_user == obj.get('object'): obj['object'] = redirect_wrap(g.external_user) @@ -506,7 +467,7 @@ def postprocess_as2_actor(actor, wrap=True): return actor elif isinstance(actor, str): if g.user and g.user.is_homepage(actor): - return actor_id(g.user) + return g.user.ap_actor() return redirect_wrap(actor) url = g.user.homepage if g.user else None @@ -520,7 +481,7 @@ def postprocess_as2_actor(actor, wrap=True): id = actor.get('id') if g.user and (not id or g.user.is_homepage(id)): - actor['id'] = actor_id(g.user) + actor['id'] = g.user.ap_actor() elif g.external_user and (not id or id == g.external_user): actor['id'] = redirect_wrap(g.external_user) @@ -562,7 +523,7 @@ def actor(protocol, domain): # TODO: unify with common.actor() actor = postprocess_as2(g.user.actor_as2 or {}) actor.update({ - 'id': actor_id(g.user), + 'id': g.user.ap_actor(), # 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: @@ -571,10 +532,10 @@ def actor(protocol, domain): # https://github.com/snarfed/bridgy-fed/issues/302#issuecomment-1324305460 # https://github.com/snarfed/bridgy-fed/issues/77 'preferredUsername': domain, - 'inbox': actor_id(g.user, 'inbox'), - 'outbox': actor_id(g.user, 'outbox'), - 'following': actor_id(g.user, 'following'), - 'followers': actor_id(g.user, 'followers'), + 'inbox': g.user.ap_actor('inbox'), + 'outbox': g.user.ap_actor('outbox'), + 'following': g.user.ap_actor('following'), + 'followers': g.user.ap_actor('followers'), 'endpoints': { 'sharedInbox': host_url('/ap/sharedInbox'), }, diff --git a/follow.py b/follow.py index 67d2b24..7f34f08 100644 --- a/follow.py +++ b/follow.py @@ -16,7 +16,7 @@ from oauth_dropins.webutil import util from oauth_dropins.webutil.testutil import NOW from oauth_dropins.webutil.util import json_dumps, json_loads -from activitypub import ActivityPub, actor_id, address +from activitypub import ActivityPub from flask_app import app import common from models import Follower, Object, PROTOCOLS @@ -98,7 +98,7 @@ def remote_follow(): if link.get('rel') == SUBSCRIBE_LINK_REL: template = link.get('template') if template and '{uri}' in template: - return redirect(template.replace('{uri}', address(g.user))) + return redirect(template.replace('{uri}', g.user.ap_address())) flash(f"Couldn't find remote follow link for {addr}") return redirect(g.user.user_page_path()) @@ -179,7 +179,7 @@ class FollowCallback(indieauth.Callback): 'type': 'Follow', 'id': follow_id, 'object': followee, - 'actor': actor_id(g.user), + 'actor': g.user.ap_actor(), 'to': [as2.PUBLIC_AUDIENCE], } obj = Object(id=follow_id, domains=[domain], labels=['user'], @@ -257,7 +257,7 @@ class UnfollowCallback(indieauth.Callback): '@context': 'https://www.w3.org/ns/activitystreams', 'type': 'Undo', 'id': unfollow_id, - 'actor': actor_id(g.user), + 'actor': g.user.ap_actor(), 'object': follower.last_follow, } diff --git a/models.py b/models.py index ee07a03..c5a7e1f 100644 --- a/models.py +++ b/models.py @@ -199,6 +199,29 @@ class User(StringIdModel, metaclass=ProtocolUserMeta): logger.info(f'Defaulting username to domain {domain}') return domain + def ap_address(self): + """Returns this user's ActivityPub address, eg '@me@foo.com'.""" + if self.direct: + return f'@{self.username()}@{self.key.id()}' + else: + return f'@{self.key.id()}@{request.host}' + + def ap_actor(self, rest=None): + """Returns this user's AS2 actor id. + + Example: 'https://fed.brid.gy/ap/bluesky/foo.com' + """ + if self.direct or rest: + # special case Web users to skip /ap/web/ prefix, for backward compatibility + url = common.host_url(self.key.id() if self.LABEL == 'web' + else f'/ap{self.user_page_path()}') + if rest: + url += f'/{rest}' + return url + # TODO(#512): drop once we fetch site if web user doesn't already exist + else: + return redirect_wrap(self.homepage) + def is_homepage(self, url): """Returns True if the given URL points to this user's home page.""" if not url: diff --git a/pages.py b/pages.py index 48a9ff6..f0d324a 100644 --- a/pages.py +++ b/pages.py @@ -14,7 +14,6 @@ from oauth_dropins.webutil import flask_util, logs, util from oauth_dropins.webutil.flask_util import error, flash, redirect from oauth_dropins.webutil.util import json_dumps, json_loads -import activitypub import common from common import DOMAIN_RE from flask_app import app, cache @@ -85,7 +84,6 @@ def user(protocol, domain): util=util, address=request.args.get('address'), g=g, - activitypub=activitypub, **locals(), ) @@ -111,7 +109,6 @@ def followers_or_following(protocol, domain, collection): util=util, address=request.args.get('address'), g=g, - activitypub=activitypub, **locals() ) @@ -143,8 +140,7 @@ def feed(protocol, domain): # syntax. maybe a fediverse kwarg down through the call chain? if format == 'html': entries = [microformats2.object_to_html(a) for a in activities] - return render_template('feed.html', util=util, g=g, - activitypub=activitypub, **locals()) + return render_template('feed.html', util=util, g=g, **locals()) elif format == 'atom': body = atom.activities_to_atom(activities, actor=actor, title=title, request_url=request.url) diff --git a/protocol.py b/protocol.py index aa6d0f6..d05ad14 100644 --- a/protocol.py +++ b/protocol.py @@ -278,10 +278,7 @@ class Protocol: follower_obj.put() # send AP Accept - # TODO: switch back to activitypub.actor_id() once this is moved into - # activitypub.py - followee_actor_url = common.host_url(g.user.key.id() if g.user.LABEL == 'web' - else f'/ap{g.user.user_page_path()}') + followee_actor_url = g.user.ap_actor() accept = { '@context': 'https://www.w3.org/ns/activitystreams', 'id': common.host_url(f'/user/{g.user.key.id()}/followers#accept-{obj.key.id()}'), diff --git a/templates/user_addresses.html b/templates/user_addresses.html index 3f057dd..2c039d6 100644 --- a/templates/user_addresses.html +++ b/templates/user_addresses.html @@ -5,7 +5,7 @@ - {{ activitypub.address(g.user) }} + {{ g.user.ap_address() }} · @@ -19,6 +19,5 @@ class="btn btn-default glyphicon glyphicon-refresh"> - diff --git a/tests/test_activitypub.py b/tests/test_activitypub.py index 62d8ffc..04a3c4c 100644 --- a/tests/test_activitypub.py +++ b/tests/test_activitypub.py @@ -24,7 +24,7 @@ from werkzeug.exceptions import BadGateway from .testutil import Fake, TestCase import activitypub -from activitypub import ActivityPub, actor_id, address +from activitypub import ActivityPub import common import models from models import Follower, Object @@ -1510,29 +1510,3 @@ class ActivityPubUtilsTest(TestCase): activitypub.postprocess_as2(obj), activitypub.postprocess_as2(activitypub.postprocess_as2(obj)), ignore=['to']) - - def test_address(self): - self.assertEqual('@user.com@user.com', address(g.user)) - - g.user.actor_as2 = {'type': 'Person'} - self.assertEqual('@user.com@user.com', address(g.user)) - - g.user.actor_as2 = {'url': 'http://foo'} - self.assertEqual('@user.com@user.com', address(g.user)) - - g.user.actor_as2 = {'url': ['http://foo', 'acct:bar@foo', 'acct:baz@user.com']} - self.assertEqual('@baz@user.com', address(g.user)) - - g.user.direct = False - self.assertEqual('@user.com@localhost', address(g.user)) - - def test_actor_id(self): - self.assertEqual('http://localhost/ap/fake/foo', - actor_id(self.make_user('foo', cls=Fake))) - - self.assertEqual('http://localhost/user.com', actor_id(g.user)) - - g.user.direct = False - self.assertEqual('http://localhost/r/https://user.com/', actor_id(g.user)) - - self.assertEqual('http://localhost/user.com/inbox', actor_id(g.user, 'inbox')) diff --git a/tests/test_models.py b/tests/test_models.py index 2feb0f1..15c01c9 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -86,6 +86,32 @@ class UserTest(TestCase): g.user.actor_as2 = ACTOR self.assertEqual(' Mrs. ☕ Foo', g.user.user_page_link()) + def test_address(self): + self.assertEqual('@y.z@y.z', g.user.ap_address()) + + g.user.actor_as2 = {'type': 'Person'} + self.assertEqual('@y.z@y.z', g.user.ap_address()) + + g.user.actor_as2 = {'url': 'http://foo'} + self.assertEqual('@y.z@y.z', g.user.ap_address()) + + g.user.actor_as2 = {'url': ['http://foo', 'acct:bar@foo', 'acct:baz@y.z']} + self.assertEqual('@baz@y.z', g.user.ap_address()) + + g.user.direct = False + self.assertEqual('@y.z@localhost', g.user.ap_address()) + + def test_ap_actor(self): + self.assertEqual('http://localhost/ap/fake/foo', + self.make_user('foo', cls=Fake).ap_actor()) + + self.assertEqual('http://localhost/y.z', g.user.ap_actor()) + + g.user.direct = False + self.assertEqual('http://localhost/r/https://y.z/', g.user.ap_actor()) + + self.assertEqual('http://localhost/y.z/inbox', g.user.ap_actor('inbox')) + class ObjectTest(TestCase): def setUp(self): diff --git a/web.py b/web.py index 9aa31e5..ad20038 100644 --- a/web.py +++ b/web.py @@ -344,7 +344,7 @@ def webmention_task(): 'id': id, 'objectType': 'activity', 'verb': 'delete', - 'actor': activitypub.actor_id(g.user), + 'actor': g.user.ap_actor(), 'object': source, }) @@ -353,8 +353,8 @@ def webmention_task(): props = obj.mf2['properties'] author_urls = microformats2.get_string_urls(props.get('author', [])) if author_urls and not g.user.is_homepage(author_urls[0]): - logger.info(f'Overriding author {author_urls[0]} with {activitypub.actor_id(g.user)}') - props['author'] = [activitypub.actor_id(g.user)] + logger.info(f'Overriding author {author_urls[0]} with {g.user.ap_actor()}') + props['author'] = [g.user.ap_actor()] logger.info(f'Converted to AS1: {obj.type}: {json_dumps(obj.as1, indent=2)}') @@ -363,7 +363,7 @@ def webmention_task(): obj.put() actor_as1 = { **obj.as1, - 'id': activitypub.actor_id(g.user), + 'id': g.user.ap_actor(), 'updated': util.now().isoformat(), } id = common.host_url(f'{obj.key.id()}#update-{util.now().isoformat()}') @@ -371,7 +371,7 @@ def webmention_task(): 'objectType': 'activity', 'verb': 'update', 'id': id, - 'actor': activitypub.actor_id(g.user), + 'actor': g.user.ap_actor(), 'object': actor_as1, }) @@ -403,7 +403,7 @@ def webmention_task(): 'objectType': 'activity', 'verb': 'update', 'id': id, - 'actor': activitypub.actor_id(g.user), + 'actor': g.user.ap_actor(), 'object': { # Mastodon requires the updated field for Updates, so # add a default value. @@ -426,7 +426,7 @@ def webmention_task(): 'objectType': 'activity', 'verb': 'post', 'id': id, - 'actor': activitypub.actor_id(g.user), + 'actor': g.user.ap_actor(), 'object': obj.as1, } obj = Object(id=id, mf2=obj.mf2, our_as1=create_as1, diff --git a/webfinger.py b/webfinger.py index d792e94..79237c2 100644 --- a/webfinger.py +++ b/webfinger.py @@ -14,7 +14,6 @@ from oauth_dropins.webutil import flask_util, util from oauth_dropins.webutil.flask_util import error from oauth_dropins.webutil.util import json_dumps, json_loads -from activitypub import actor_id, address import common from flask_app import app, cache from models import User @@ -56,7 +55,7 @@ class Actor(flask_util.XrdOrJrd): actor = g.user.to_as1() or {} homepage = g.user.homepage - handle = address(g.user) + handle = g.user.ap_address() logger.info(f'Generating WebFinger data for {domain}') logger.info(f'AS1 actor: {actor}') @@ -95,13 +94,13 @@ class Actor(flask_util.XrdOrJrd): # WARNING: in python 2 sometimes request.host_url lost port, # http://localhost:8080 would become just http://localhost. no # clue how or why. pay attention here if that happens again. - 'href': actor_id(g.user), + 'href': g.user.ap_actor(), }, { # AP reads this and sharedInbox from the AS2 actor, not # webfinger, so strictly speaking, it's probably not needed here. 'rel': 'inbox', 'type': as2.CONTENT_TYPE, - 'href': actor_id(g.user, 'inbox'), + 'href': g.user.ap_actor('inbox'), }, { # https://www.w3.org/TR/activitypub/#sharedInbox 'rel': 'sharedInbox',