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,