From 9e906f18e49870cb3f7d1ae318c4ecd3540cbd42 Mon Sep 17 00:00:00 2001 From: Ryan Barrett Date: Tue, 30 May 2023 17:24:49 -0700 Subject: [PATCH] move address, actor_id from User to activitypub.py, /web-site to web.py for #512 --- activitypub.py | 65 ++++++++++++++++++++++++++++------- follow.py | 8 ++--- models.py | 25 -------------- pages.py | 36 ++++--------------- protocol.py | 5 ++- templates/user_addresses.html | 3 +- tests/test_activitypub.py | 28 ++++++++++++++- tests/test_models.py | 26 -------------- tests/test_pages.py | 40 --------------------- tests/test_web.py | 49 ++++++++++++++++++++++---- web.py | 47 ++++++++++++++++++++----- webfinger.py | 9 ++--- 12 files changed, 180 insertions(+), 161 deletions(-) diff --git a/activitypub.py b/activitypub.py index ba6b8ab..067e5d3 100644 --- a/activitypub.py +++ b/activitypub.py @@ -27,7 +27,7 @@ from common import ( TLD_BLOCKLIST, ) from models import Follower, Object, PROTOCOLS, Target, User -from protocol import 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): +class ActivityPub(User, protocol.Protocol): """ActivityPub protocol class.""" LABEL = 'activitypub' @@ -58,7 +58,7 @@ class ActivityPub(User, Protocol): target = getattr(obj, 'target_as2', None) activity = obj.as2 or postprocess_as2(as2.from_as1(obj.as1), target=target) - activity['actor'] = g.user.actor_id() + activity['actor'] = actor_id(g.user) return signed_post(url, log_data=True, data=activity) # TODO: return bool or otherwise unify return value with others @@ -214,6 +214,45 @@ class ActivityPub(User, 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) @@ -267,7 +306,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=user.actor_id(), + auth = HTTPSignatureAuth(secret=user.private_pem(), key_id=actor_id(user), algorithm='rsa-sha256', sign_header='signature', headers=HTTP_SIG_HEADERS) @@ -371,13 +410,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'] = g.user.actor_id() + obj['id'] = actor_id(g.user) 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'] = g.user.actor_id() + obj['object'] = actor_id(g.user) elif g.external_user and g.external_user == obj.get('object'): obj['object'] = redirect_wrap(g.external_user) @@ -467,7 +506,7 @@ def postprocess_as2_actor(actor, wrap=True): return actor elif isinstance(actor, str): if g.user and g.user.is_homepage(actor): - return g.user.actor_id() + return actor_id(g.user) return redirect_wrap(actor) url = g.user.homepage if g.user else None @@ -481,7 +520,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'] = g.user.actor_id() + actor['id'] = actor_id(g.user) elif g.external_user and (not id or id == g.external_user): actor['id'] = redirect_wrap(g.external_user) @@ -523,7 +562,7 @@ def actor(protocol, domain): # TODO: unify with common.actor() actor = postprocess_as2(g.user.actor_as2 or {}) actor.update({ - 'id': g.user.actor_id(), + 'id': actor_id(g.user), # 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: @@ -532,10 +571,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': g.user.actor_id('inbox'), - 'outbox': g.user.actor_id('outbox'), - 'following': g.user.actor_id('following'), - 'followers': g.user.actor_id('followers'), + 'inbox': actor_id(g.user, 'inbox'), + 'outbox': actor_id(g.user, 'outbox'), + 'following': actor_id(g.user, 'following'), + 'followers': actor_id(g.user, 'followers'), 'endpoints': { 'sharedInbox': host_url('/ap/sharedInbox'), }, diff --git a/follow.py b/follow.py index 59bfe61..67d2b24 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 +from activitypub import ActivityPub, actor_id, address 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}', g.user.address())) + return redirect(template.replace('{uri}', address(g.user))) 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': g.user.actor_id(), + 'actor': actor_id(g.user), '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': g.user.actor_id(), + 'actor': actor_id(g.user), 'object': follower.last_follow, } diff --git a/models.py b/models.py index 9e288f8..ee07a03 100644 --- a/models.py +++ b/models.py @@ -199,31 +199,6 @@ class User(StringIdModel, metaclass=ProtocolUserMeta): logger.info(f'Defaulting username to domain {domain}') return domain - # TODO(#512): move to activitypub.py? - def 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}' - - # TODO(#512): move to activitypub.py? - def actor_id(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 53de723..48a9ff6 100644 --- a/pages.py +++ b/pages.py @@ -14,9 +14,10 @@ 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 -from flask_app import app, cache +import activitypub import common from common import DOMAIN_RE +from flask_app import app, cache from models import fetch_page, Follower, Object, PAGE_SIZE, PROTOCOLS, User from web import Web @@ -45,34 +46,6 @@ def docs(): return render_template('docs.html') -@app.get('/web-site') -@flask_util.cached(cache, datetime.timedelta(days=1)) -def enter_web_site(): - return render_template('enter_web_site.html') - -# TODO(#512): move to webmention.py? -@app.post('/web-site') -def check_web_site(): - url = request.values['url'] - domain = util.domain_from_link(url, minimize=False) - if not domain: - flash(f'No domain found in {url}') - return render_template('enter_web_site.html') - - g.user = Web.get_or_create(domain, direct=True) - try: - g.user = g.user.verify() - except BaseException as e: - code, body = util.interpret_http_exception(e) - if code: - flash(f"Couldn't connect to {url}: {e}") - return render_template('enter_web_site.html') - raise - - g.user.put() - return redirect(g.user.user_page_path()) - - @app.get(f'/user/') @app.get(f'/user//feed') @app.get(f'/user//') @@ -112,6 +85,7 @@ def user(protocol, domain): util=util, address=request.args.get('address'), g=g, + activitypub=activitypub, **locals(), ) @@ -137,6 +111,7 @@ def followers_or_following(protocol, domain, collection): util=util, address=request.args.get('address'), g=g, + activitypub=activitypub, **locals() ) @@ -168,7 +143,8 @@ 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, **locals()) + return render_template('feed.html', util=util, g=g, + activitypub=activitypub, **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 580074f..aa6d0f6 100644 --- a/protocol.py +++ b/protocol.py @@ -278,7 +278,10 @@ class Protocol: follower_obj.put() # send AP Accept - followee_actor_url = g.user.actor_id() + # 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()}') 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 b21766f..3f057dd 100644 --- a/templates/user_addresses.html +++ b/templates/user_addresses.html @@ -5,7 +5,7 @@ - {{ g.user.address() }} + {{ activitypub.address(g.user) }} · @@ -19,5 +19,6 @@ class="btn btn-default glyphicon glyphicon-refresh"> + diff --git a/tests/test_activitypub.py b/tests/test_activitypub.py index 04a3c4c..62d8ffc 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 +from activitypub import ActivityPub, actor_id, address import common import models from models import Follower, Object @@ -1510,3 +1510,29 @@ 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 f29aeb0..2feb0f1 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -86,32 +86,6 @@ 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.address()) - - g.user.actor_as2 = {'type': 'Person'} - self.assertEqual('@y.z@y.z', g.user.address()) - - g.user.actor_as2 = {'url': 'http://foo'} - self.assertEqual('@y.z@y.z', g.user.address()) - - g.user.actor_as2 = {'url': ['http://foo', 'acct:bar@foo', 'acct:baz@y.z']} - self.assertEqual('@baz@y.z', g.user.address()) - - g.user.direct = False - self.assertEqual('@y.z@localhost', g.user.address()) - - def test_actor_id(self): - self.assertEqual('http://localhost/ap/fake/foo', - self.make_user('foo', cls=Fake).actor_id()) - - self.assertEqual('http://localhost/y.z', g.user.actor_id()) - - g.user.direct = False - self.assertEqual('http://localhost/r/https://y.z/', g.user.actor_id()) - - self.assertEqual('http://localhost/y.z/inbox', g.user.actor_id('inbox')) - class ObjectTest(TestCase): def setUp(self): diff --git a/tests/test_pages.py b/tests/test_pages.py index ac0e962..4694478 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -1,7 +1,4 @@ """Unit tests for pages.py.""" -from unittest.mock import patch - -from flask import get_flashed_messages from granary import as2, atom, microformats2, rss from granary.tests.test_bluesky import REPLY_BSKY from granary.tests.test_as1 import ( @@ -115,43 +112,6 @@ class PagesTest(TestCase): got = self.client.get('/web/user.com?before=2024-01-01+01:01:01&after=2023-01-01+01:01:01') self.assert_equals(400, got.status_code) - @patch('requests.get') - def test_check_web_site(self, mock_get): - redir = 'http://localhost/.well-known/webfinger?resource=acct:user.com@user.com' - mock_get.side_effect = ( - requests_response('', status=302, redirected_url=redir), - requests_response(ACTOR_HTML, url='https://user.com/', - content_type=common.CONTENT_TYPE_HTML), - ) - - got = self.client.post('/web-site', data={'url': 'https://user.com/'}) - self.assert_equals(302, got.status_code) - self.assert_equals('/web/user.com', got.headers['Location']) - - user = Web.get_by_id('user.com') - self.assertTrue(user.has_hcard) - self.assertEqual('Person', user.actor_as2['type']) - self.assertEqual('http://localhost/user.com', user.actor_as2['id']) - - def test_check_web_site_bad_url(self): - got = self.client.post('/web-site', data={'url': '!!!'}) - self.assert_equals(200, got.status_code) - self.assertEqual(['No domain found in !!!'], get_flashed_messages()) - self.assertEqual(1, Web.query().count()) - - @patch('requests.get') - def test_check_web_site_fetch_fails(self, mock_get): - redir = 'http://localhost/.well-known/webfinger?resource=acct:orig@orig' - mock_get.side_effect = ( - requests_response('', status=302, redirected_url=redir), - requests_response('', status=503), - ) - - got = self.client.post('/web-site', data={'url': 'https://orig/'}) - self.assert_equals(200, got.status_code, got.headers) - self.assertTrue(get_flashed_messages()[0].startswith( - "Couldn't connect to https://orig/: ")) - def test_followers(self): self.make_user('bar.com') Follower.get_or_create('bar.com', 'https://no/stored/follow') diff --git a/tests/test_web.py b/tests/test_web.py index 9d0eacb..935750b 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1,11 +1,11 @@ # coding=utf-8 """Unit tests for webmention.py.""" import copy -from unittest import mock +from unittest.mock import patch from urllib.parse import urlencode import feedparser -from flask import g +from flask import g, get_flashed_messages from granary import as1, as2, atom, microformats2 from httpsig.sign import HeaderSigner from oauth_dropins.webutil import appengine_config, util @@ -158,8 +158,8 @@ DELETE_AS2 = { 'to': [as2.PUBLIC_AUDIENCE], } -@mock.patch('requests.post') -@mock.patch('requests.get') +@patch('requests.post') +@patch('requests.get') class WebTest(testutil.TestCase): def setUp(self): super().setUp() @@ -413,7 +413,7 @@ class WebTest(testutil.TestCase): self.assertEqual(400, got.status_code) self.assertEqual(0, Object.query().count()) - @mock.patch('oauth_dropins.webutil.appengine_config.tasks_client.create_task') + @patch('oauth_dropins.webutil.appengine_config.tasks_client.create_task') def test_make_task(self, mock_create_task, mock_get, mock_post): mock_get.side_effect = [self.note, self.actor] @@ -1512,9 +1512,44 @@ http://this/404s 'https://user', '://user.com'): self.assertFalse(self.user.is_homepage(url), url) + def test_check_web_site(self, mock_get, _): + redir = 'http://localhost/.well-known/webfinger?resource=acct:user.com@user.com' + mock_get.side_effect = ( + requests_response('', status=302, redirected_url=redir), + requests_response(ACTOR_HTML, url='https://user.com/', + content_type=CONTENT_TYPE_HTML), + ) -@mock.patch('requests.post') -@mock.patch('requests.get') + got = self.client.post('/web-site', data={'url': 'https://user.com/'}) + self.assert_equals(302, got.status_code) + self.assert_equals('/web/user.com', got.headers['Location']) + + user = Web.get_by_id('user.com') + self.assertTrue(user.has_hcard) + self.assertEqual('Person', user.actor_as2['type']) + self.assertEqual('http://localhost/user.com', user.actor_as2['id']) + + def test_check_web_site_bad_url(self, _, __): + got = self.client.post('/web-site', data={'url': '!!!'}) + self.assert_equals(200, got.status_code) + self.assertEqual(['No domain found in !!!'], get_flashed_messages()) + self.assertEqual(1, Web.query().count()) + + def test_check_web_site_fetch_fails(self, mock_get, _): + redir = 'http://localhost/.well-known/webfinger?resource=acct:orig@orig' + mock_get.side_effect = ( + requests_response('', status=302, redirected_url=redir), + requests_response('', status=503), + ) + + got = self.client.post('/web-site', data={'url': 'https://orig/'}) + self.assert_equals(200, got.status_code, got.headers) + self.assertTrue(get_flashed_messages()[0].startswith( + "Couldn't connect to https://orig/: ")) + + +@patch('requests.post') +@patch('requests.get') class WebProtocolTest(testutil.TestCase): def setUp(self): diff --git a/web.py b/web.py index 42d07fc..9aa31e5 100644 --- a/web.py +++ b/web.py @@ -1,10 +1,11 @@ """Handles inbound webmentions.""" +import datetime import difflib import logging from urllib.parse import urlencode, urljoin, urlparse import feedparser -from flask import g, redirect, request +from flask import g, redirect, render_template, request from flask.views import View from google.cloud.ndb import Key from granary import as1, as2, microformats2 @@ -19,8 +20,8 @@ from requests import HTTPError, RequestException, URLRequired from werkzeug.exceptions import BadGateway, BadRequest, HTTPException, NotFound import activitypub -from flask_app import app import common +from flask_app import app, cache from models import Follower, Object, PROTOCOLS, Target, User from protocol import Protocol @@ -231,6 +232,34 @@ class Web(User, Protocol): PROTOCOLS['webmention'] = Web +@app.get('/web-site') +@flask_util.cached(cache, datetime.timedelta(days=1)) +def enter_web_site(): + return render_template('enter_web_site.html') + + +@app.post('/web-site') +def check_web_site(): + url = request.values['url'] + domain = util.domain_from_link(url, minimize=False) + if not domain: + flash(f'No domain found in {url}') + return render_template('enter_web_site.html') + + g.user = Web.get_or_create(domain, direct=True) + try: + g.user = g.user.verify() + except BaseException as e: + code, body = util.interpret_http_exception(e) + if code: + flash(f"Couldn't connect to {url}: {e}") + return render_template('enter_web_site.html') + raise + + g.user.put() + return redirect(g.user.user_page_path()) + + @app.post('/webmention') def webmention_external(): """Handles inbound webmention, enqueue task to process. @@ -315,7 +344,7 @@ def webmention_task(): 'id': id, 'objectType': 'activity', 'verb': 'delete', - 'actor': g.user.actor_id(), + 'actor': activitypub.actor_id(g.user), 'object': source, }) @@ -324,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 {g.user.actor_id()}') - props['author'] = [g.user.actor_id()] + logger.info(f'Overriding author {author_urls[0]} with {activitypub.actor_id(g.user)}') + props['author'] = [activitypub.actor_id(g.user)] logger.info(f'Converted to AS1: {obj.type}: {json_dumps(obj.as1, indent=2)}') @@ -334,7 +363,7 @@ def webmention_task(): obj.put() actor_as1 = { **obj.as1, - 'id': g.user.actor_id(), + 'id': activitypub.actor_id(g.user), 'updated': util.now().isoformat(), } id = common.host_url(f'{obj.key.id()}#update-{util.now().isoformat()}') @@ -342,7 +371,7 @@ def webmention_task(): 'objectType': 'activity', 'verb': 'update', 'id': id, - 'actor': g.user.actor_id(), + 'actor': activitypub.actor_id(g.user), 'object': actor_as1, }) @@ -374,7 +403,7 @@ def webmention_task(): 'objectType': 'activity', 'verb': 'update', 'id': id, - 'actor': g.user.actor_id(), + 'actor': activitypub.actor_id(g.user), 'object': { # Mastodon requires the updated field for Updates, so # add a default value. @@ -397,7 +426,7 @@ def webmention_task(): 'objectType': 'activity', 'verb': 'post', 'id': id, - 'actor': g.user.actor_id(), + 'actor': activitypub.actor_id(g.user), 'object': obj.as1, } obj = Object(id=id, mf2=obj.mf2, our_as1=create_as1, diff --git a/webfinger.py b/webfinger.py index da0fac6..d792e94 100644 --- a/webfinger.py +++ b/webfinger.py @@ -14,8 +14,9 @@ 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 flask_app import app, cache +from activitypub import actor_id, address import common +from flask_app import app, cache from models import User from web import Web @@ -55,7 +56,7 @@ class Actor(flask_util.XrdOrJrd): actor = g.user.to_as1() or {} homepage = g.user.homepage - handle = g.user.address() + handle = address(g.user) logger.info(f'Generating WebFinger data for {domain}') logger.info(f'AS1 actor: {actor}') @@ -94,13 +95,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': g.user.actor_id(), + 'href': actor_id(g.user), }, { # 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': g.user.actor_id('inbox'), + 'href': actor_id(g.user, 'inbox'), }, { # https://www.w3.org/TR/activitypub/#sharedInbox 'rel': 'sharedInbox',