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': [{