diff --git a/activitypub.py b/activitypub.py
index 9366ed0..6d56044 100644
--- a/activitypub.py
+++ b/activitypub.py
@@ -54,6 +54,24 @@ class ActivityPub(User, Protocol):
"""
LABEL = 'activitypub'
+ def web_url(self):
+ """Returns this user's web URL aka web_url, eg 'https://foo.com/'."""
+ return util.get_url(self.actor_as2) or self.ap_actor()
+
+ def ap_address(self):
+ """Returns this user's ActivityPub address, eg '@foo.com@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 ActivityPub/AS2 actor id URL.
+
+ Eg 'https://fed.brid.gy/foo.com'
+ """
+ return self.key.id()
+
@classmethod
def send(cls, obj, url, log_data=True):
"""Delivers an activity to an inbox URL."""
@@ -373,13 +391,13 @@ def postprocess_as2(activity, target=None, wrap=True):
activity['object'] = target_id
elif not id:
obj['id'] = util.get_first(obj, 'url') or target_id
- elif g.user and g.user.is_homepage(id):
+ elif g.user and g.user.is_web_url(id):
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')):
+ if g.user and g.user.is_web_url(obj.get('object')):
obj['object'] = g.user.ap_actor()
elif g.external_user and g.external_user == obj.get('object'):
obj['object'] = redirect_wrap(g.external_user)
@@ -469,11 +487,11 @@ def postprocess_as2_actor(actor, wrap=True):
if not actor:
return actor
elif isinstance(actor, str):
- if g.user and g.user.is_homepage(actor):
+ if g.user and g.user.is_web_url(actor):
return g.user.ap_actor()
return redirect_wrap(actor)
- url = g.user.homepage if g.user else None
+ url = g.user.web_url() if g.user else None
urls = util.get_list(actor, 'url')
if not urls and url:
urls = [url]
@@ -483,7 +501,7 @@ def postprocess_as2_actor(actor, wrap=True):
urls[0] = redirect_wrap(urls[0])
id = actor.get('id')
- if g.user and (not id or g.user.is_homepage(id)):
+ if g.user and (not id or g.user.is_web_url(id)):
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)
diff --git a/common.py b/common.py
index b7de648..8fd561b 100644
--- a/common.py
+++ b/common.py
@@ -95,7 +95,7 @@ def pretty_link(url, text=None, **kwargs):
text: str
kwargs: passed through to :func:`webutil.util.pretty_link`
"""
- if g.user and g.user.is_homepage(url):
+ if g.user and g.user.is_web_url(url):
return g.user.user_page_link()
if text is None:
diff --git a/models.py b/models.py
index 34441ce..97ef23e 100644
--- a/models.py
+++ b/models.py
@@ -103,11 +103,6 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
"""Try to prevent instantiation. Use subclasses instead."""
raise NotImplementedError()
- # TODO(#512): move this and is_homepage to web.py?
- @property
- def homepage(self):
- return f'https://{self.key.id()}/'
-
def _post_put_hook(self, future):
logger.info(f'Wrote {self.key}')
@@ -200,16 +195,48 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
logger.info(f'Defaulting username to key id {id}')
return id
+ def web_url(self):
+ """Returns this user's web URL aka web_url, eg 'https://foo.com/'.
+
+ To be implemented by subclasses.
+
+ Returns:
+ str
+ """
+ raise NotImplementedError()
+
+ def is_web_url(self, url):
+ """Returns True if the given URL is this user's web URL (web_url).
+
+ Args:
+ url: str
+
+ Returns:
+ boolean
+ """
+ if not url:
+ return False
+
+ url = url.strip().rstrip('/')
+ parsed_url = urllib.parse.urlparse(url)
+ if parsed_url.scheme not in ('http', 'https', ''):
+ return False
+
+ this = self.web_url().rstrip('/')
+ parsed_this = urllib.parse.urlparse(this)
+
+ return (url == this or url == parsed_this.netloc or
+ parsed_url[1:] == parsed_this[1:]) # ignore http vs https
+
def ap_address(self):
"""Returns this user's ActivityPub address, eg '@me@foo.com'.
To be implemented by subclasses.
+
+ Returns:
+ str
"""
raise NotImplementedError()
- 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 ActivityPub/AS2 actor id.
@@ -217,33 +244,14 @@ class User(StringIdModel, metaclass=ProtocolUserMeta):
Eg 'https://fed.brid.gy/ap/bluesky/foo.com'
To be implemented by subclasses.
+
+ Args:
+ rest: str, optional, appended to URL path
+
+ Returns:
+ str
"""
raise NotImplementedError()
- 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:
- return False
-
- url = url.strip().rstrip('/')
- if url == self.key.id():
- return True
-
- parsed = urllib.parse.urlparse(url)
- return (parsed.netloc == self.key.id()
- and parsed.scheme in ('', 'http', 'https')
- and not parsed.path and not parsed.query
- and not parsed.params and not parsed.fragment)
def user_page_path(self, rest=None):
"""Returns the user's Bridgy Fed user page path."""
diff --git a/pages.py b/pages.py
index f0d324a..04ceb2f 100644
--- a/pages.py
+++ b/pages.py
@@ -131,7 +131,7 @@ def feed(protocol, domain):
actor = {
'displayName': domain,
- 'url': g.user.homepage,
+ 'url': g.user.web_url(),
}
title = f'Bridgy Fed feed for {domain}'
@@ -277,7 +277,7 @@ def nodeinfo():
'name': 'bridgy-fed',
'version': os.getenv('GAE_VERSION'),
'repository': 'https://github.com/snarfed/bridgy-fed',
- 'homepage': 'https://fed.brid.gy/',
+ 'web_url': 'https://fed.brid.gy/',
},
'protocols': [
'activitypub',
diff --git a/tests/test_activitypub.py b/tests/test_activitypub.py
index 26f6981..b65a23e 100644
--- a/tests/test_activitypub.py
+++ b/tests/test_activitypub.py
@@ -278,11 +278,11 @@ class ActivityPubTest(TestCase):
self.assertTrue(type.startswith(as2.CONTENT_TYPE), type)
self.assertEqual({
'preferredUsername': 'fake.com',
- 'id': 'http://bf/fake/ap',
- 'inbox': 'http://bf/fake/ap/inbox',
- 'outbox': 'http://bf/fake/ap/outbox',
- 'following': 'http://bf/fake/ap/following',
- 'followers': 'http://bf/fake/ap/followers',
+ 'id': 'http://bf/fake.com/ap',
+ 'inbox': 'http://bf/fake.com/ap/inbox',
+ 'outbox': 'http://bf/fake.com/ap/outbox',
+ 'following': 'http://bf/fake.com/ap/following',
+ 'followers': 'http://bf/fake.com/ap/followers',
'endpoints': {
'sharedInbox': 'http://localhost/ap/sharedInbox',
},
@@ -1259,8 +1259,8 @@ class ActivityPubUtilsTest(TestCase):
def setUp(self):
super().setUp()
self.request_context.push()
- g.user = self.make_user('user.com', has_hcard=True,
- actor_as2=ACTOR)
+ g.user = self.make_user('user.com', has_hcard=True, actor_as2=ACTOR)
+
def tearDown(self):
self.request_context.pop()
super().tearDown()
@@ -1302,16 +1302,16 @@ class ActivityPubUtilsTest(TestCase):
'actor': {
'id': 'baj',
'preferredUsername': 'site',
- 'url': 'http://localhost/r/https://site/',
+ 'url': 'http://localhost/r/https://site',
},
'attributedTo': [{
'id': 'bar',
'preferredUsername': 'site',
- 'url': 'http://localhost/r/https://site/',
+ 'url': 'http://localhost/r/https://site',
}, {
'id': 'baz',
'preferredUsername': 'site',
- 'url': 'http://localhost/r/https://site/',
+ 'url': 'http://localhost/r/https://site',
}],
'to': [as2.PUBLIC_AUDIENCE],
}, activitypub.postprocess_as2({
@@ -1542,3 +1542,21 @@ class ActivityPubUtilsTest(TestCase):
activitypub.postprocess_as2(obj),
activitypub.postprocess_as2(activitypub.postprocess_as2(obj)),
ignore=['to'])
+
+ def test_ap_actor(self):
+ user = self.make_user('http://foo/actor', cls=ActivityPub)
+ self.assertEqual('http://foo/actor', user.ap_actor())
+
+ def test_ap_address(self):
+ user = self.make_user('http://foo/actor', cls=ActivityPub)
+ self.assertEqual('http://foo/actor', user.ap_actor())
+
+ def test_web_url(self):
+ user = self.make_user('http://foo/actor', cls=ActivityPub)
+ self.assertEqual('http://foo/actor', user.web_url())
+
+ user.actor_as2 = copy.deepcopy(ACTOR) # no url
+ self.assertEqual('http://foo/actor', user.web_url())
+
+ user.actor_as2['url'] = ['http://my/url']
+ self.assertEqual('http://my/url', user.web_url())
diff --git a/tests/test_models.py b/tests/test_models.py
index 2feb0f1..42e1c79 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -86,6 +86,14 @@ class UserTest(TestCase):
g.user.actor_as2 = ACTOR
self.assertEqual(' Mrs. ☕ Foo', g.user.user_page_link())
+ def test_is_web_url(self):
+ for url in 'y.z', '//y.z', 'http://y.z', 'https://y.z':
+ self.assertTrue(g.user.is_web_url(url), url)
+
+ for url in (None, '', 'user', 'com', 'com.user', 'ftp://y.z',
+ 'https://user', '://y.z'):
+ self.assertFalse(g.user.is_web_url(url), url)
+
class ObjectTest(TestCase):
def setUp(self):
diff --git a/tests/test_web.py b/tests/test_web.py
index cf538f4..88bd5c6 100644
--- a/tests/test_web.py
+++ b/tests/test_web.py
@@ -1501,16 +1501,31 @@ http://this/404s
'preferredUsername': 'user.com',
})
- def test_homepage(self, _, __):
- self.assertEqual('https://user.com/', self.user.homepage)
+ def test_web_url(self, _, __):
+ self.assertEqual('https://user.com/', self.user.web_url())
- def test_is_homepage(self, _, __):
- for url in 'user.com', '//user.com', 'http://user.com', 'https://user.com':
- self.assertTrue(self.user.is_homepage(url), url)
+ def test_ap_address(self, *_):
+ self.assertEqual('@user.com@user.com', g.user.ap_address())
- for url in (None, '', 'user', 'com', 'com.user', 'ftp://user.com',
- 'https://user', '://user.com'):
- self.assertFalse(self.user.is_homepage(url), url)
+ g.user.actor_as2 = {'type': 'Person'}
+ self.assertEqual('@user.com@user.com', g.user.ap_address())
+
+ g.user.actor_as2 = {'url': 'http://foo'}
+ self.assertEqual('@user.com@user.com', g.user.ap_address())
+
+ g.user.actor_as2 = {'url': ['http://foo', 'acct:bar@foo', 'acct:baz@user.com']}
+ self.assertEqual('@baz@user.com', g.user.ap_address())
+
+ g.user.direct = False
+ self.assertEqual('@user.com@localhost', g.user.ap_address())
+
+ def test_ap_actor(self, *_):
+ self.assertEqual('http://localhost/user.com', g.user.ap_actor())
+
+ g.user.direct = False
+ self.assertEqual('http://localhost/r/https://user.com/', g.user.ap_actor())
+
+ self.assertEqual('http://localhost/user.com/inbox', g.user.ap_actor('inbox'))
def test_check_web_site(self, mock_get, _):
redir = 'http://localhost/.well-known/webfinger?resource=acct:user.com@user.com'
@@ -1547,29 +1562,6 @@ http://this/404s
self.assertTrue(get_flashed_messages()[0].startswith(
"Couldn't connect to https://orig/: "))
- def test_ap_address(self, *_):
- self.assertEqual('@user.com@user.com', g.user.ap_address())
-
- g.user.actor_as2 = {'type': 'Person'}
- self.assertEqual('@user.com@user.com', g.user.ap_address())
-
- g.user.actor_as2 = {'url': 'http://foo'}
- self.assertEqual('@user.com@user.com', g.user.ap_address())
-
- g.user.actor_as2 = {'url': ['http://foo', 'acct:bar@foo', 'acct:baz@user.com']}
- self.assertEqual('@baz@user.com', g.user.ap_address())
-
- g.user.direct = False
- self.assertEqual('@user.com@localhost', g.user.ap_address())
-
- def test_ap_actor(self, *_):
- self.assertEqual('http://localhost/user.com', g.user.ap_actor())
-
- g.user.direct = False
- self.assertEqual('http://localhost/r/https://user.com/', g.user.ap_actor())
-
- self.assertEqual('http://localhost/user.com/inbox', g.user.ap_actor('inbox'))
-
@patch('requests.post')
@patch('requests.get')
diff --git a/tests/testutil.py b/tests/testutil.py
index c15f11b..5bc077e 100644
--- a/tests/testutil.py
+++ b/tests/testutil.py
@@ -44,11 +44,14 @@ class Fake(User, protocol.Protocol):
# in-order list of ids
fetched = []
+ def web_url(self):
+ return f'https://{self.key.id()}'
+
def ap_address(self):
- return '@fake@fake'
+ return f'@{self.key.id()}@fake'
def ap_actor(self, rest=None):
- return 'http://bf/fake/ap' + (f'/{rest}' if rest else '')
+ return f'http://bf/{self.key.id()}/ap' + (f'/{rest}' if rest else '')
@classmethod
def send(cls, obj, url, log_data=True):
diff --git a/web.py b/web.py
index 131dc2f..31b3f1e 100644
--- a/web.py
+++ b/web.py
@@ -49,6 +49,10 @@ class Web(User, Protocol):
def _get_kind(cls):
return 'MagicKey'
+ def web_url(self):
+ """Returns this user's web URL aka web_url, eg 'https://foo.com/'."""
+ return f'https://{self.key.id()}/'
+
def ap_address(self):
"""Returns this user's ActivityPub address, eg '@foo.com@foo.com'.
@@ -74,7 +78,7 @@ class Web(User, Protocol):
return url
# TODO(#512): drop once we fetch site if web user doesn't already exist
- return common.redirect_wrap(self.homepage)
+ return common.redirect_wrap(self.web_url())
def verify(self):
"""Fetches site a couple ways to check for redirects and h-card.
@@ -94,7 +98,7 @@ class Web(User, Protocol):
root_site = f'https://{root}/'
try:
resp = util.requests_get(root_site, gateway=False)
- if resp.ok and self.is_homepage(resp.url):
+ if resp.ok and self.is_web_url(resp.url):
logger.info(f'{root_site} redirects to {resp.url} ; using {root} instead')
root_user = Web.get_or_create(root)
self.use_instead = root_user.key
@@ -108,7 +112,7 @@ class Web(User, Protocol):
self.has_redirects = False
self.redirects_error = None
try:
- url = urljoin(self.homepage, path)
+ url = urljoin(self.web_url(), path)
resp = util.requests_get(url, gateway=False)
domain_urls = ([f'https://{domain}/' for domain in common.DOMAINS] +
[common.host_url()])
@@ -129,7 +133,7 @@ class Web(User, Protocol):
# check home page
try:
- obj = Web.load(self.homepage, gateway=True)
+ obj = Web.load(self.web_url(), gateway=True)
self.actor_as2 = activitypub.postprocess_as2(as2.from_as1(obj.as1))
self.has_hcard = True
except (BadRequest, NotFound):
@@ -168,12 +172,12 @@ class Web(User, Protocol):
check_backlink: bool, optional, whether to require a link to Bridgy Fed
"""
url = obj.key.id()
- is_homepage = ((g.user and g.user.is_homepage(url)) or
+ is_web_url = ((g.user and g.user.is_web_url(url)) or
(g.external_user and g.external_user == url))
require_backlink = None
- if check_backlink or (check_backlink is None and not is_homepage):
+ if check_backlink or (check_backlink is None and not is_web_url):
require_backlink = common.host_url().rstrip('/')
try:
@@ -186,8 +190,8 @@ class Web(User, Protocol):
error(f'id {urlparse(url).fragment} not found in {url}')
# find mf2 item
- if is_homepage:
- logger.info(f"{url} is user's homepage")
+ if is_web_url:
+ logger.info(f"{url} is user's web url")
entry = mf2util.representative_hcard(parsed, parsed['url'])
logger.info(f'Representative h-card: {json_dumps(entry, indent=2)}')
if not entry:
@@ -200,7 +204,7 @@ class Web(User, Protocol):
# store final URL in mf2 object, and also default url property to it,
# since that's the fallback for AS1/AS2 id
entry['url'] = parsed['url']
- if is_homepage:
+ if is_web_url:
entry.setdefault('rel-urls', {}).update(parsed.get('rel-urls', {}))
props = entry.setdefault('properties', {})
props.setdefault('url', [parsed['url']])
@@ -209,7 +213,7 @@ class Web(User, Protocol):
# run full authorship algorithm if necessary: https://indieweb.org/authorship
# duplicated in microformats2.json_to_object
author = util.get_first(props, 'author')
- if not isinstance(author, dict) and not is_homepage:
+ if not isinstance(author, dict) and not is_web_url:
logger.info(f'Fetching full authorship for author {author}')
author = mf2util.find_author({'items': [entry]}, hentry=entry,
fetch_mf2_func=util.fetch_mf2)
@@ -329,7 +333,7 @@ def webmention_interactive():
"""
try:
webmention_external()
- flash(f'Updating fediverse profile from {g.user.key.id()}...')
+ flash(f'Updating fediverse profile from {g.user.key.id()}...')
except HTTPException as e:
flash(util.linkify(str(e.description), pretty=True))
@@ -379,14 +383,14 @@ def webmention_task():
# set actor to user
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]):
+ if author_urls and not g.user.is_web_url(author_urls[0]):
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)}')
# if source is home page, send an actor Update to followers' instances
- if g.user.is_homepage(obj.key.id()):
+ if g.user.is_web_url(obj.key.id()):
obj.put()
actor_as1 = {
**obj.as1,
diff --git a/webfinger.py b/webfinger.py
index 79237c2..53fd3ff 100644
--- a/webfinger.py
+++ b/webfinger.py
@@ -54,20 +54,17 @@ class Actor(flask_util.XrdOrJrd):
error(f'No user or web site found for {domain}', status=404)
actor = g.user.to_as1() or {}
- homepage = g.user.homepage
- handle = g.user.ap_address()
-
logger.info(f'Generating WebFinger data for {domain}')
logger.info(f'AS1 actor: {actor}')
urls = util.dedupe_urls(util.get_list(actor, 'urls') +
util.get_list(actor, 'url') +
- [homepage])
+ [g.user.web_url()])
logger.info(f'URLs: {urls}')
canonical_url = urls[0]
# generate webfinger content
data = util.trim_nulls({
- 'subject': 'acct:' + handle.lstrip('@'),
+ 'subject': 'acct:' + g.user.ap_address().lstrip('@'),
'aliases': urls,
'links':
[{