diff --git a/config.py b/config.py index dafdb23..964349b 100644 --- a/config.py +++ b/config.py @@ -8,8 +8,9 @@ from oauth_dropins.webutil import appengine_info, util # otherwise. SESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True -# Change to Lax if/when we add IndieAuth for anything. -SESSION_COOKIE_SAMESITE = 'Strict' +# Not strict because we flash messages after cross-site redirects for OAuth, +# which strict blocks. +SESSION_COOKIE_SAMESITE = 'Lax' if appengine_info.DEBUG: ENV = 'development' diff --git a/follow.py b/follow.py index 44b200f..9a448ee 100644 --- a/follow.py +++ b/follow.py @@ -8,20 +8,71 @@ import logging import urllib.parse from flask import redirect, request +from granary import as2 +from oauth_dropins import indieauth from oauth_dropins.webutil import flask_util, util from oauth_dropins.webutil.flask_util import error, flash +from oauth_dropins.webutil import util from oauth_dropins.webutil.util import json_dumps, json_loads from app import app import common -from models import User +from models import Follower, User logger = logging.getLogger(__name__) SUBSCRIBE_LINK_REL = 'http://ostatus.org/schema/1.0/subscribe' -@app.post('/follow') +def fetch_webfinger(addr): + """Fetches and returns an address's Webfinger data. + + On failure, flashes a message and returns None. + + Args: + addr: str, a Webfinger-compatible address, eg @x@y, acct:x@y, or + https://x/y + + Returns: + dict, fetched Webfinger data + """ + addr = addr.strip().strip('@') + split = addr.split('@') + if len(split) == 2: + addr_domain = split[1] + resource = f'acct:{addr}' + elif util.is_web(addr): + addr_domain = util.domain_from_link(addr, minimize=False) + resource = addr + else: + flash('Enter a fediverse address in @user@domain.social format') + return None + + try: + resp = util.requests_get( + f'https://{addr_domain}/.well-known/webfinger?resource={resource}') + except BaseException as e: + if util.is_connection_failure(e): + flash(f"Couldn't connect to {addr_domain}") + return None + raise + + if not resp.ok: + flash(f'WebFinger on {addr_domain} returned HTTP {resp.status_code}') + return None + + try: + data = resp.json() + except ValueError as e: + logger.warning(f'Got {e}', exc_info=True) + flash(f'WebFinger on {addr_domain} returned non-JSON') + return None + + logger.info(f'Got: {json_dumps(data, indent=2)}') + return data + + +@app.post('/remote-follow') def remote_follow(): """Discovers and redirects to a remote follow page for a given user.""" logger.info(f'Got: {request.values}') @@ -31,42 +82,11 @@ def remote_follow(): if not user: error(f'No Bridgy Fed user found for domain {domain}') - addr = request.values['address'].strip().strip('@') - split = addr.split('@') - if len(split) == 2: - addr_domain = split[1] - resource = f'acct:{addr}' - elif util.is_web(addr): - addr_domain = util.domain_from_link(addr, minimize=False) - resource = addr - else: - flash('Enter your fediverse address in @user@domain.social format') + webfinger = fetch_webfinger(request.values['address']) + if webfinger is None: return redirect(f'/user/{domain}') - # look up remote user via webfinger - try: - resp = util.requests_get( - f'https://{addr_domain}/.well-known/webfinger?resource={resource}') - except BaseException as e: - if util.is_connection_failure(e): - flash(f"Couldn't connect to {addr_domain}") - return redirect(f'/user/{domain}') - raise - - if not resp.ok: - flash(f'WebFinger on {addr_domain} returned HTTP {resp.status_code}') - return redirect(f'/user/{domain}') - - # find remote follow link and redirect - try: - data = resp.json() - except ValueError as e: - logger.warning(f'Got {e}', exc_info=True) - flash(f'WebFinger on {domain} returned non-JSON') - return redirect(f'/user/{domain}') - - logger.info(f'Got: {json_dumps(data, indent=2)}') - for link in data.get('links', []): + for link in webfinger.get('links', []): if link.get('rel') == SUBSCRIBE_LINK_REL: template = link.get('template') if template and '{uri}' in template: @@ -74,3 +94,75 @@ def remote_follow(): flash(f"Couldn't find remote follow link for {addr}") return redirect(f'/user/{domain}') + + +class FollowStart(indieauth.Start): + """Starts the IndieAuth flow to add a follower to an existing user.""" + def dispatch_request(self): + address = request.form['address'] + + try: + return redirect(self.redirect_url(state=address)) + except Exception as e: + if util.is_connection_failure(e) or util.interpret_http_exception(e)[0]: + flash(f"Couldn't fetch your web site: {e}") + return redirect(f'/user/{domain}/following?address={address}') + raise + + +class FollowCallback(indieauth.Callback): + """IndieAuth callback to add a follower to an existing user.""" + def finish(self, auth_entity, state=None): + if not auth_entity: + return + + domain = util.domain_from_link(auth_entity.key.id()) + if not User.get_by_id(domain): + error(f'No user for domain {domain}') + + # addr = state.get('address') + # if not addr: + # error(f'state missing address field') + addr = state + assert addr + webfinger = fetch_webfinger(addr) + if webfinger is None: + return redirect(f'/user/{domain}/following') + + as2_url = None + for link in webfinger.get('links', []): + if link.get('rel') == 'self' and link.get('type') == as2.CONTENT_TYPE: + as2_url = link.get('href') + + if not as2_url: + flash(f"Couldn't find ActivityPub profile link for {addr}") + return redirect(f'/user/{domain}/following') + + resp = common.get_as2(as2_url) + obj = resp.json() + id = obj.get('id') + inbox = obj.get('inbox') + if not id or not inbox: + flash(f"AS2 profile {as2_url} missing id or inbox") + return redirect(f'/user/{domain}/following') + + common.signed_post(inbox, data={ + '@context': 'https://www.w3.org/ns/activitystreams', + 'type': 'Follow', + 'id': 'TODO', + 'object': id, + 'actor': common.host_url(domain), + 'to': [as2.PUBLIC_AUDIENCE], + }) + + Follower.get_or_create(dest=id, src=domain, status='active', + last_follow=json_dumps({})) + flash(f'Followed {addr}.') + return redirect(f'/user/{domain}/following') + + +app.add_url_rule('/follow/start', + view_func=FollowStart.as_view('follow_start', '/follow/callback'), + methods=['POST']) +app.add_url_rule('/follow/callback', + view_func=FollowCallback.as_view('follow_callback', 'unused')) diff --git a/indieauth_client_id b/indieauth_client_id new file mode 100644 index 0000000..0bd24fe --- /dev/null +++ b/indieauth_client_id @@ -0,0 +1 @@ +https://fed.brid.gy/ diff --git a/pages.py b/pages.py index fb9f44d..9ac60dc 100644 --- a/pages.py +++ b/pages.py @@ -97,6 +97,7 @@ def user(domain): follow_url=request.values.get('url'), logs=logs, util=util, + **request.args, **locals(), ) diff --git a/templates/following.html b/templates/following.html index 989b5ab..79a3b4f 100644 --- a/templates/following.html +++ b/templates/following.html @@ -6,7 +6,20 @@ {% include "user_addresses.html" %} -
Following
+
Following
+ +
+
+

+ + + + +

+
+
{% include "_followers.html" %} diff --git a/templates/user.html b/templates/user.html index 7ad418b..cd65ca6 100644 --- a/templates/user.html +++ b/templates/user.html @@ -49,7 +49,7 @@
-
+

- - {% include "activities.html" %} {% endblock %} diff --git a/tests/test_follow.py b/tests/test_follow.py index 19ca5d7..c709d53 100644 --- a/tests/test_follow.py +++ b/tests/test_follow.py @@ -2,10 +2,15 @@ """ from unittest.mock import patch +from flask import get_flashed_messages +from granary import as2 +from oauth_dropins import indieauth +from oauth_dropins.webutil import util from oauth_dropins.webutil.testutil import requests_response +from oauth_dropins.webutil.util import json_dumps, json_loads import common -from models import User +from models import Follower, User from . import testutil WEBFINGER = requests_response({ @@ -16,31 +21,36 @@ WEBFINGER = requests_response({ 'links': [{ 'rel': 'http://ostatus.org/schema/1.0/subscribe', 'template': 'https://bar/follow?uri={uri}' + }, { + 'rel': 'self', + 'type': as2.CONTENT_TYPE, + 'href': 'https://bar/actor' }], }) + @patch('requests.get') -class FollowTest(testutil.TestCase): +class RemoteFollowTest(testutil.TestCase): def setUp(self): super().setUp() User.get_or_create('me') def test_follow_no_domain(self, mock_get): - got = self.client.post('/follow?address=@foo@bar') + got = self.client.post('/remote-follow?address=@foo@bar') self.assertEqual(400, got.status_code) def test_follow_no_address(self, mock_get): - got = self.client.post('/follow?domain=baz.com') + got = self.client.post('/remote-follow?domain=baz.com') self.assertEqual(400, got.status_code) def test_follow_no_user(self, mock_get): - got = self.client.post('/follow?address=@foo@bar&domain=baz.com') + got = self.client.post('/remote-follow?address=@foo@bar&domain=baz.com') self.assertEqual(400, got.status_code) def test_follow(self, mock_get): mock_get.return_value = WEBFINGER - got = self.client.post('/follow?address=@foo@bar&domain=me') + got = self.client.post('/remote-follow?address=@foo@bar&domain=me') self.assertEqual(302, got.status_code) self.assertEqual('https://bar/follow?uri=@me@me', got.headers['Location']) @@ -51,7 +61,7 @@ class FollowTest(testutil.TestCase): def test_follow_url(self, mock_get): mock_get.return_value = WEBFINGER - got = self.client.post('/follow?address=https://bar/foo&domain=me') + got = self.client.post('/remote-follow?address=https://bar/foo&domain=me') self.assertEqual(302, got.status_code) self.assertEqual('https://bar/follow?uri=@me@me', got.headers['Location']) @@ -65,20 +75,102 @@ class FollowTest(testutil.TestCase): 'links': [{'rel': 'other', 'template': 'meh'}], }) - got = self.client.post('/follow?address=https://bar/foo&domain=me') + got = self.client.post('/remote-follow?address=https://bar/foo&domain=me') self.assertEqual(302, got.status_code) self.assertEqual('/user/me', got.headers['Location']) def test_follow_no_webfinger_subscribe_link(self, mock_get): mock_get.return_value = requests_response(status_code=500) - got = self.client.post('/follow?address=https://bar/foo&domain=me') + got = self.client.post('/remote-follow?address=https://bar/foo&domain=me') self.assertEqual(302, got.status_code) self.assertEqual('/user/me', got.headers['Location']) def test_follow_no_webfinger_subscribe_link(self, mock_get): mock_get.return_value = requests_response('not json') - got = self.client.post('/follow?address=https://bar/foo&domain=me') + got = self.client.post('/remote-follow?address=https://bar/foo&domain=me') self.assertEqual(302, got.status_code) self.assertEqual('/user/me', got.headers['Location']) + + +@patch('requests.post') +@patch('requests.get') +class AddFollowerTest(testutil.TestCase): + + def test_start(self, mock_get, _): + resp = self.client.post('/follow/start', data={ + 'me': 'https://snarfed.org', + 'address': '@foo@bar', + }) + self.assertEqual(302, resp.status_code) + self.assertTrue(resp.headers['Location'].startswith(indieauth.INDIEAUTH_URL), + resp.headers['Location']) + + def test_callback(self, mock_get, mock_post): + mock_post.side_effect = ( + requests_response('me=https://snarfed.org'), + requests_response('OK'), # AP Follow to inbox + ) + mock_get.side_effect = ( + # oauth-dropins indieauth https://snarfed.org fetch for user json + requests_response(''), + WEBFINGER, + self.as2_resp({ + 'type': 'Person', + 'id': 'https://bar/id', + 'inbox': 'http://bar/inbox', + }), + ) + User.get_or_create('snarfed.org') + + state = util.encode_oauth_state({ + 'endpoint': 'http://auth/endpoint', + 'me': 'https://snarfed.org', + 'state': '@foo@bar', + }) + with self.client: + resp = self.client.get(f'/follow/callback?code=my_code&state={state}') + self.assertEqual(302, resp.status_code) + self.assertEqual('/user/snarfed.org/following',resp.headers['Location']) + self.assertEqual(['Followed @foo@bar.'], get_flashed_messages()) + + mock_get.assert_has_calls(( + self.req('https://bar/.well-known/webfinger?resource=acct:foo@bar'), + self.as2_req('https://bar/actor'), + )) + mock_post.assert_has_calls(( + self.req('http://auth/endpoint', data={ + 'me': 'https://snarfed.org', + 'state': '@foo@bar', + 'code': 'my_code', + 'client_id': indieauth.INDIEAUTH_CLIENT_ID, + 'redirect_uri': 'http://localhost/follow/callback', + }), + )) + inbox_args, inbox_kwargs = mock_post.call_args_list[-1] + self.assertEqual(('http://bar/inbox',), inbox_args) + self.assert_equals({ + '@context': 'https://www.w3.org/ns/activitystreams', + 'type': 'Follow', + 'id': 'TODO', + 'actor': 'http://localhost/snarfed.org', + 'object': 'https://bar/id', + 'to': [as2.PUBLIC_AUDIENCE], + }, json_loads(inbox_kwargs['data'])) + + followers = Follower.query().fetch() + self.assertEqual(1, len(followers)) + self.assertEqual('https://bar/id snarfed.org', followers[0].key.id()) + + def test_callback_missing_user(self, mock_get, mock_post): + mock_post.return_value = requests_response('me=https://snarfed.org') + + state = util.encode_oauth_state({ + 'endpoint': 'http://auth/endpoint', + 'me': 'https://snarfed.org', + 'state': '@foo@bar', + }) + with self.client: + resp = self.client.get(f'/follow/callback?code=my_code&state={state}') + self.assertEqual(400, resp.status_code) diff --git a/webfinger.py b/webfinger.py index a0d1fa1..763bde1 100644 --- a/webfinger.py +++ b/webfinger.py @@ -120,12 +120,12 @@ class Actor(flask_util.XrdOrJrd): # clue how or why. pay attention here if that happens again. 'href': common.host_url(domain), }, { + # 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': common.host_url(f'{domain}/inbox'), }, { - # AP reads this from the AS2 actor, not webfinger, so strictly - # speaking, it's probably not needed here. # https://www.w3.org/TR/activitypub/#sharedInbox 'rel': 'sharedInbox', 'type': as2.CONTENT_TYPE,