diff --git a/app.py b/app.py index 24e0aec..90e9bdd 100644 --- a/app.py +++ b/app.py @@ -35,4 +35,4 @@ cache = Cache(app) util.set_user_agent('Bridgy Fed (https://fed.brid.gy/)') -import activitypub, add_webmention, pages, redirect, render, salmon, superfeedr, webfinger, webmention +import activitypub, add_webmention, follow, pages, redirect, render, salmon, superfeedr, webfinger, webmention diff --git a/follow.py b/follow.py new file mode 100644 index 0000000..442236a --- /dev/null +++ b/follow.py @@ -0,0 +1,76 @@ +"""Remote follow handler. + +https://github.com/snarfed/bridgy-fed/issues/60 +https://socialhub.activitypub.rocks/t/what-is-the-current-spec-for-remote-follow/2020 +https://www.rfc-editor.org/rfc/rfc7033 +""" +import logging +import urllib.parse + +from flask import redirect, request +from oauth_dropins.webutil import flask_util, util +from oauth_dropins.webutil.flask_util import error, flash +from oauth_dropins.webutil.util import json_dumps, json_loads + +from app import app +import common +from models import User + +logger = logging.getLogger(__name__) + +SUBSCRIBE_LINK_REL = 'http://ostatus.org/schema/1.0/subscribe' + + +@app.post('/follow') +def remote_follow(): + """Discovers and redirects to a remote follow page for a given user.""" + logger.info(f'Got: {request.values}') + + domain = request.values['domain'] + user = User.get_by_id(domain) + 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 addr.startswith('http://') or addr.startswith('https://'): + addr_domain = util.domain_from_link(addr, minimize=False) + resource = addr + else: + flash('Enter your fediverse address in @user@domain.com format') + 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', []): + if link.get('rel') == SUBSCRIBE_LINK_REL: + template = link.get('template') + if template and '{uri}' in template: + return redirect(template.replace('{uri}', user.address())) + + flash(f"Couldn't find remote follow link for {addr}") + return redirect(f'/user/{domain}') diff --git a/templates/user.html b/templates/user.html index dbf10a0..24000bb 100644 --- a/templates/user.html +++ b/templates/user.html @@ -42,6 +42,18 @@ · ActivityPub +
+
+

+ + + + +

+
+
+ {% include "activities.html" %} diff --git a/tests/test_follow.py b/tests/test_follow.py new file mode 100644 index 0000000..19ca5d7 --- /dev/null +++ b/tests/test_follow.py @@ -0,0 +1,84 @@ +"""Unit tests for follow.py. +""" +from unittest.mock import patch + +from oauth_dropins.webutil.testutil import requests_response + +import common +from models import User +from . import testutil + +WEBFINGER = requests_response({ + 'subject': 'acct:foo@bar', + 'aliases': [ + 'https://bar/foo', + ], + 'links': [{ + 'rel': 'http://ostatus.org/schema/1.0/subscribe', + 'template': 'https://bar/follow?uri={uri}' + }], +}) + +@patch('requests.get') +class FollowTest(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') + self.assertEqual(400, got.status_code) + + def test_follow_no_address(self, mock_get): + got = self.client.post('/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') + 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') + self.assertEqual(302, got.status_code) + self.assertEqual('https://bar/follow?uri=@me@me', + got.headers['Location']) + + mock_get.assert_has_calls(( + self.req('https://bar/.well-known/webfinger?resource=acct:foo@bar'), + )) + + def test_follow_url(self, mock_get): + mock_get.return_value = WEBFINGER + got = self.client.post('/follow?address=https://bar/foo&domain=me') + self.assertEqual(302, got.status_code) + self.assertEqual('https://bar/follow?uri=@me@me', got.headers['Location']) + + mock_get.assert_has_calls(( + self.req('https://bar/.well-known/webfinger?resource=https://bar/foo'), + )) + + def test_follow_no_webfinger_subscribe_link(self, mock_get): + mock_get.return_value = requests_response({ + 'subject': 'acct:foo@bar', + 'links': [{'rel': 'other', 'template': 'meh'}], + }) + + got = self.client.post('/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') + 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') + self.assertEqual(302, got.status_code) + self.assertEqual('/user/me', got.headers['Location'])