From 65f3ef3cc71b4a5dea56f47ba61736a5b44f1fdf Mon Sep 17 00:00:00 2001 From: Ryan Barrett Date: Sun, 20 Nov 2022 09:38:46 -0800 Subject: [PATCH] show fediverse address on user page UI, including custom username for #281 --- common.py | 30 +++++++++++++++++++++++++- models.py | 15 ++++++++++++- static/style.css | 2 +- templates/docs.html | 10 +-------- templates/index.html | 4 ++-- templates/user.html | 10 +++++---- tests/test_models.py | 50 ++++++++++++++++++++++++++++++++------------ webfinger.py | 10 ++------- 8 files changed, 92 insertions(+), 39 deletions(-) diff --git a/common.py b/common.py index 4c6d10a..ab1aa15 100644 --- a/common.py +++ b/common.py @@ -16,6 +16,7 @@ from oauth_dropins.webutil.util import json_dumps, json_loads import requests from werkzeug.exceptions import BadGateway +import common from models import Activity, User logger = logging.getLogger(__name__) @@ -476,9 +477,11 @@ def actor(domain, user=None): actor = postprocess_as2( as2.from_as1(microformats2.json_to_object(hcard)), user=user) + urls = util.dedupe_urls(microformats2.get_string_urls([hcard])) + username = common.get_username(domain, urls) actor.update({ 'id': f'{request.host_url}{domain}', - 'preferredUsername': domain, + 'preferredUsername': username, 'inbox': f'{request.host_url}{domain}/inbox', 'outbox': f'{request.host_url}{domain}/outbox', 'following': f'{request.host_url}{domain}/following', @@ -490,3 +493,28 @@ def actor(domain, user=None): logger.info(f'Generated AS2 actor: {json_dumps(actor, indent=2)}') return actor + + +def get_username(domain, urls): + """Returns a user's preferred username from an acct: url, if available. + + If there's no acct: URL, returns domain. + + Args: + domain: str + urls: sequence of str + + Returns: str + """ + assert domain + assert urls + + for url in urls: + if url.startswith('acct:'): + urluser, urldomain = util.parse_acct_uri(url) + if urldomain == domain: + logger.info(f'Found custom username: urluser') + return urluser + + logger.info(f'Defaulting username to domain {domain}') + return domain diff --git a/models.py b/models.py index 6edfee1..3522c2c 100644 --- a/models.py +++ b/models.py @@ -10,6 +10,7 @@ from django_salmon import magicsigs from flask import request from google.cloud import ndb from oauth_dropins.webutil.models import StringIdModel +from oauth_dropins.webutil.util import json_dumps, json_loads import common @@ -34,6 +35,7 @@ class User(StringIdModel): private_exponent = ndb.StringProperty(required=True) has_redirects = ndb.BooleanProperty() has_hcard = ndb.BooleanProperty() + actor_as2 = ndb.TextProperty() @classmethod def _get_kind(cls): @@ -72,6 +74,16 @@ class User(StringIdModel): magicsigs.base64_to_long(str(self.private_exponent)))) return rsa.exportKey(format='PEM') + def address(self): + """Returns this user's ActivityPub address, eg '@me@foo.com'.""" + domain = self.key.id() + + username = None + if self.actor_as2 is not None: + username = json_loads(self.actor_as2).get('preferredUsername') + + return f'@{username or domain}@{domain}' + def verify(self): """Fetches site a couple ways to check for redirects and h-card.""" domain = self.key.id() @@ -91,9 +103,10 @@ class User(StringIdModel): # check home page try: - common.actor(self.key.id(), user=self) + self.actor_as2 = json_dumps(common.actor(self.key.id(), user=self)) self.has_hcard = True except (BadRequest, NotFound): + self.actor_as2 = None self.has_hcard = False diff --git a/static/style.css b/static/style.css index eb934e7..7c6de40 100644 --- a/static/style.css +++ b/static/style.css @@ -320,7 +320,7 @@ button[disabled]:hover { border-radius: 1em; } -.btn-home img { +.btn-home img, .logo { height: 1em; } diff --git a/templates/docs.html b/templates/docs.html index c81d11a..d3c1c4a 100644 --- a/templates/docs.html +++ b/templates/docs.html @@ -69,11 +69,7 @@ We're aware of the sites below, and we've made progress on some, but they're not
  • -Federated social network identities take the form @username@example.com, like an email address with a leading @. Your site's identity via Bridgy Fed will be @yourdomain.com@yourdomain.com. Once you've set up Atom on your site, people can follow you at that address. -

    - -

    -Most fedsocnets also publish Atom themselves, so you can add profile URLs like mastodon.technology/@snarfed to your reader and see their posts there too. +Federated social network identities take the form @username@example.com, like an email address with a leading @. Your site's identity via Bridgy Fed will be @yourdomain.com@yourdomain.com.

    @@ -159,10 +155,6 @@ https://en.support.wordpress.com/site-redirect/ -

    -If you want people on OStatus sites like Hubzilla to see your posts, your web site will also need to support WebSub (nÊe PubSubHubbub). Specifically, your Atom feed needs to advertise it. Example details for Mastodon. If you're on a CMS, it may already have a plugin! WordPress has a couple, and Known has it built in. Or you can use Superfeedr or Switchboard. -

    -
  • diff --git a/templates/index.html b/templates/index.html index f82e3f8..4ec0bdf 100644 --- a/templates/index.html +++ b/templates/index.html @@ -11,7 +11,7 @@

    Cross-post to a Mastodon account:
    -@user@mastodon.server

    +@you@mastodon.server

          
         /
     🌐 —  — 
    @@ -25,7 +25,7 @@
     
     {% endif %}
     
    -
    -

    - 🌐 {{ domain }} + + {{ user.address() }} | + 🌐 {{ domain }}

    +
    +

    - | {{ followers }} follower{% if followers != '1' %}s{% endif %} + {{ followers }} follower{% if followers != '1' %}s{% endif %} | following {{ following }} | HTML, Atom, diff --git a/tests/test_models.py b/tests/test_models.py index e07e486..b89008b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,6 +3,7 @@ from unittest import mock from oauth_dropins.webutil.testutil import requests_response +from oauth_dropins.webutil.util import json_dumps, json_loads from app import app from models import User, Activity @@ -15,7 +16,7 @@ class UserTest(testutil.TestCase): super(UserTest, self).setUp() self.user = User.get_or_create('y.z') - def test_magic_key_get_or_create(self): + def test_get_or_create(self): assert self.user.mod assert self.user.public_exponent assert self.user.private_exponent @@ -39,52 +40,75 @@ class UserTest(testutil.TestCase): self.assertTrue(pem.decode().startswith('-----BEGIN RSA PRIVATE KEY-----\n'), pem) self.assertTrue(pem.decode().endswith('-----END RSA PRIVATE KEY-----'), pem) + def test_address(self): + self.assertEqual('@y.z@y.z', self.user.address()) + + self.user.actor_as2 = '{"type": "Person"}' + self.assertEqual('@y.z@y.z', self.user.address()) + + self.user.actor_as2 = '{"preferredUsername": "foo"}' + self.assertEqual('@foo@y.z', self.user.address()) + @mock.patch('requests.get') def test_verify(self, mock_get): self.assertFalse(self.user.has_redirects) self.assertFalse(self.user.has_hcard) - def check(redirects, hcard): + def check(redirects, hcard, actor): with app.test_request_context('/'): self.user.verify() - with self.subTest(redirects=redirects, hcard=hcard): - self.assertEqual(redirects, bool(self.user.has_redirects)) - self.assertEqual(hcard, bool(self.user.has_hcard)) + with self.subTest(redirects=redirects, hcard=hcard, actor=actor): + self.assert_equals(redirects, bool(self.user.has_redirects)) + self.assert_equals(hcard, bool(self.user.has_hcard)) + if actor is None: + self.assertIsNone(self.user.actor_as2) + else: + got = {k: v for k, v in json_loads(self.user.actor_as2).items() + if k in actor} + self.assert_equals(actor, got) # both fail empty = requests_response('') mock_get.side_effect = [empty, empty] - check(False, False) + check(False, False, None) # redirect works but strips query params, no h-card half_redir = requests_response( status=302, redirected_url='http://localhost/.well-known/webfinger') no_hcard = requests_response('') mock_get.side_effect = [half_redir, no_hcard] - check(False, False) + check(False, False, None) # redirect works, non-representative h-card full_redir = requests_response( status=302, allow_redirects=False, redirected_url='http://localhost/.well-known/webfinger?resource=acct:y.z@y.z') bad_hcard = requests_response( - 'me', + 'acct:me@y.z', url='https://y.z/', ) mock_get.side_effect = [full_redir, bad_hcard] - check(True, False) + check(True, False, None) # both work - hcard = requests_response( - 'me', + hcard = requests_response(""" + + me + Masto +""", url='https://y.z/', ) mock_get.side_effect = [full_redir, hcard] - check(True, True) + check(True, True, { + 'type': 'Person', + 'name': 'me', + 'url': 'http://localhost/r/https://y.z/', + 'preferredUsername': 'myself', + }) # reset mock_get.side_effect = [empty, empty] - check(False, False) + check(False, False, None) class ActivityTest(testutil.TestCase): diff --git a/webfinger.py b/webfinger.py index f0d2927..34cbd2c 100644 --- a/webfinger.py +++ b/webfinger.py @@ -64,14 +64,8 @@ class Actor(flask_util.XrdOrJrd): urls = util.dedupe_urls(props.get('url', []) + [resp.url]) canonical_url = urls[0] - acct = f'{domain}@{domain}' - for url in urls: - if url.startswith('acct:'): - urluser, urldomain = util.parse_acct_uri(url) - if urldomain == domain: - acct = f'{urluser}@{domain}' - logger.info(f'Found custom username: acct:{acct}') - break + username = common.get_username(domain, urls) + acct = f'{username}@{domain}' # discover atom feed, if any atom = parsed.find('link', rel='alternate', type=common.CONTENT_TYPE_ATOM)