diff --git a/follow.py b/follow.py
index 7bb7eab..16fbde5 100644
--- a/follow.py
+++ b/follow.py
@@ -102,6 +102,7 @@ class FollowStart(indieauth.Start):
"""Starts the IndieAuth flow to add a follower to an existing user."""
def dispatch_request(self):
address = request.form['address']
+ domain = request.form['me']
try:
return redirect(self.redirect_url(state=address))
@@ -173,9 +174,71 @@ class FollowCallback(indieauth.Callback):
return redirect(f'/user/{domain}/following')
+class UnfollowStart(indieauth.Start):
+ """Starts the IndieAuth flow to add a follower to an existing user."""
+ def dispatch_request(self):
+ key = request.form['key']
+ domain = request.form['me']
+
+ try:
+ return redirect(self.redirect_url(state=key))
+ 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')
+ raise
+
+
+class UnfollowCallback(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}')
+
+ follower = Follower.get_by_id(state)
+ if not follower:
+ error(f'Bad state {state}')
+
+ followee_id = follower.dest
+ last_follow = json_loads(follower.last_follow)
+ followee = last_follow['object']
+ inbox = last_follow['object']['inbox']
+
+ timestamp = NOW.replace(microsecond=0, tzinfo=None).isoformat()
+ unfollow_id = common.host_url(f'/user/{domain}/following#undo-{timestamp}-{followee_id}')
+ unfollow_as2 = {
+ '@context': 'https://www.w3.org/ns/activitystreams',
+ 'type': 'Undo',
+ 'id': unfollow_id,
+ 'actor': common.host_url(domain),
+ 'object': last_follow,
+ }
+ common.signed_post(inbox, data=unfollow_as2)
+
+ follower.status = 'inactive'
+ follower.put()
+ Activity.get_or_create(source=unfollow_id, target=followee_id, domain=[domain],
+ direction='out', protocol='activitypub', status='complete',
+ source_as2=json_dumps(unfollow_as2))
+
+ link = util.pretty_link(util.get_url(followee) or followee_id)
+ flash(f'Unfollowed {link}.')
+ 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'),
methods=['GET'])
+app.add_url_rule('/unfollow/start',
+ view_func=UnfollowStart.as_view('unfollow_start', '/unfollow/callback'),
+ methods=['POST'])
+app.add_url_rule('/unfollow/callback',
+ view_func=UnfollowCallback.as_view('unfollow_callback', 'unused'),
+ methods=['GET'])
diff --git a/templates/_followers.html b/templates/_followers.html
index c67b48a..c49a22b 100644
--- a/templates/_followers.html
+++ b/templates/_followers.html
@@ -3,13 +3,21 @@
{% for f in followers %}
-
+
{% if f.picture %}
{% endif %}
- {{ f.name or '' }}
+ {{ f.name or '' }}
{{ f.handle }}
+ {% if page_name == 'following' %}
+
+ {% endif %}
{% else %}
None yet. Check back soon!
diff --git a/templates/followers.html b/templates/followers.html
index bf20dda..1f9a170 100644
--- a/templates/followers.html
+++ b/templates/followers.html
@@ -8,6 +8,8 @@
Followers
-{% include "_followers.html" %}
+{% with page_name="followers" %}
+ {% include "_followers.html" %}
+{% endwith %}
{% endblock %}
diff --git a/templates/following.html b/templates/following.html
index b76c76d..a8ea27a 100644
--- a/templates/following.html
+++ b/templates/following.html
@@ -11,7 +11,7 @@
-{% include "_followers.html" %}
+{% with page_name="following" %}
+ {% include "_followers.html" %}
+{% endwith %}
{% endblock %}
diff --git a/tests/test_follow.py b/tests/test_follow.py
index bff21d2..7279c9e 100644
--- a/tests/test_follow.py
+++ b/tests/test_follow.py
@@ -1,5 +1,6 @@
"""Unit tests for follow.py.
"""
+import copy
from unittest.mock import patch
from flask import get_flashed_messages
@@ -27,6 +28,29 @@ WEBFINGER = requests_response({
'href': 'https://bar/actor'
}],
})
+FOLLOWEE = {
+ 'type': 'Person',
+ 'id': 'https://bar/id',
+ 'url': 'https://bar/url',
+ 'inbox': 'http://bar/inbox',
+}
+FOLLOW_ADDRESS = {
+ '@context': 'https://www.w3.org/ns/activitystreams',
+ 'type': 'Follow',
+ 'id': f'http://localhost/user/snarfed.org/following#2022-01-02T03:04:05-@foo@bar',
+ 'actor': 'http://localhost/snarfed.org',
+ 'object': FOLLOWEE,
+ 'to': [as2.PUBLIC_AUDIENCE],
+}
+FOLLOW_URL = copy.deepcopy(FOLLOW_ADDRESS)
+FOLLOW_URL['id'] = f'http://localhost/user/snarfed.org/following#2022-01-02T03:04:05-https://bar/actor'
+UNDO_FOLLOW = {
+ '@context': 'https://www.w3.org/ns/activitystreams',
+ 'type': 'Undo',
+ 'id': f'http://localhost/user/snarfed.org/following#undo-2022-01-02T03:04:05-https://bar/id',
+ 'actor': 'http://localhost/snarfed.org',
+ 'object': FOLLOW_ADDRESS,
+}
@patch('requests.get')
@@ -96,7 +120,7 @@ class RemoteFollowTest(testutil.TestCase):
@patch('requests.post')
@patch('requests.get')
-class AddFollowerTest(testutil.TestCase):
+class FollowTest(testutil.TestCase):
def test_start(self, mock_get, _):
resp = self.client.post('/follow/start', data={
@@ -108,36 +132,31 @@ class AddFollowerTest(testutil.TestCase):
resp.headers['Location'])
def test_callback_address(self, mock_get, mock_post):
- self._test_callback('@foo@bar', WEBFINGER, mock_get, mock_post)
+ mock_get.side_effect = (
+ # oauth-dropins indieauth https://snarfed.org fetch for user json
+ requests_response(''),
+ WEBFINGER,
+ self.as2_resp(FOLLOWEE),
+ )
+ self._test_callback('@foo@bar', FOLLOW_ADDRESS, mock_get, mock_post)
mock_get.assert_has_calls((
self.req('https://bar/.well-known/webfinger?resource=acct:foo@bar'),
))
def test_callback_url(self, mock_get, mock_post):
- self._test_callback('https://bar/actor', None, mock_get, mock_post)
+ mock_get.side_effect = (
+ requests_response(''),
+ self.as2_resp(FOLLOWEE),
+ )
+ self._test_callback('https://bar/actor', FOLLOW_URL, mock_get, mock_post)
- def _test_callback(self, input, webfinger_data, mock_get, mock_post):
- followee = {
- 'type': 'Person',
- 'id': 'https://bar/id',
- 'url': 'https://bar/url',
- 'inbox': 'http://bar/inbox',
- }
+ def _test_callback(self, input, expected_follow, mock_get, mock_post):
+ User.get_or_create('snarfed.org')
mock_post.side_effect = (
requests_response('me=https://snarfed.org'),
requests_response('OK'), # AP Follow to inbox
)
- gets = [
- # oauth-dropins indieauth https://snarfed.org fetch for user json
- requests_response(''),
- self.as2_resp(followee),
- ]
- if webfinger_data:
- gets.insert(1, webfinger_data)
- mock_get.side_effect = gets
-
- User.get_or_create('snarfed.org')
state = util.encode_oauth_state({
'endpoint': 'http://auth/endpoint',
@@ -148,47 +167,30 @@ class AddFollowerTest(testutil.TestCase):
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([f'Followed {input}.'], get_flashed_messages())
+ self.assertEqual([f'Followed {input}.'],
+ get_flashed_messages())
mock_get.assert_has_calls((
self.as2_req('https://bar/actor'),
))
- mock_post.assert_has_calls((
- self.req('http://auth/endpoint', data={
- 'me': 'https://snarfed.org',
- 'state': input,
- '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(input, mock_post.call_args_list[0][1]['data']['state'])
+ inbox_args, inbox_kwargs = mock_post.call_args_list[1]
self.assertEqual(('http://bar/inbox',), inbox_args)
-
- expected_follow = {
- '@context': 'https://www.w3.org/ns/activitystreams',
- 'type': 'Follow',
- 'id': f'http://localhost/user/snarfed.org/following#2022-01-02T03:04:05-{input}',
- 'actor': 'http://localhost/snarfed.org',
- 'object': followee,
- 'to': [as2.PUBLIC_AUDIENCE],
- }
self.assert_equals(expected_follow, json_loads(inbox_kwargs['data']))
- expected_follow_json = json_dumps(expected_follow, sort_keys=True)
+ follow_json = json_dumps(expected_follow, sort_keys=True)
followers = Follower.query().fetch()
self.assert_entities_equal(
- Follower(id='https://bar/id snarfed.org', last_follow=expected_follow_json,
+ Follower(id='https://bar/id snarfed.org', last_follow=follow_json,
src='snarfed.org', dest='https://bar/id', status='active'),
followers,
ignore=['created', 'updated'])
+ id = f'http://localhost/user/snarfed.org/following__2022-01-02T03:04:05-{input} https://bar/id'
activities = Activity.query().fetch()
self.assert_entities_equal(
- [Activity(id=f'http://localhost/user/snarfed.org/following__2022-01-02T03:04:05-{input} https://bar/id',
- domain=['snarfed.org'], status='complete',
- protocol='activitypub', direction='out',
- source_as2=expected_follow_json)],
+ [Activity(id=id, domain=['snarfed.org'], status='complete',
+ protocol='activitypub', direction='out', source_as2=follow_json)],
activities,
ignore=['created', 'updated'])
@@ -203,3 +205,72 @@ class AddFollowerTest(testutil.TestCase):
with self.client:
resp = self.client.get(f'/follow/callback?code=my_code&state={state}')
self.assertEqual(400, resp.status_code)
+
+
+@patch('requests.post')
+@patch('requests.get')
+class UnfollowTest(testutil.TestCase):
+
+ def setUp(self):
+ super().setUp()
+ self.follower = Follower(
+ id='https://bar/id snarfed.org', last_follow=json_dumps(FOLLOW_ADDRESS),
+ src='snarfed.org', dest='https://bar/id', status='active',
+ ).put()
+
+ def test_start(self, mock_get, _):
+ resp = self.client.post('/unfollow/start', data={
+ 'me': 'https://snarfed.org',
+ 'key': self.follower.id(),
+ })
+ 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):
+ User.get_or_create('snarfed.org')
+ mock_post.side_effect = (
+ requests_response('me=https://snarfed.org'),
+ requests_response('OK'), # AP Undo Follow to inbox
+ )
+ # oauth-dropins indieauth https://snarfed.org fetch for user json
+ mock_get.return_value = requests_response('')
+
+ state = util.encode_oauth_state({
+ 'endpoint': 'http://auth/endpoint',
+ 'me': 'https://snarfed.org',
+ 'state': self.follower.id(),
+ })
+ with self.client:
+ resp = self.client.get(f'/unfollow/callback?code=my_code&state={state}')
+ self.assertEqual(302, resp.status_code)
+ self.assertEqual('/user/snarfed.org/following',resp.headers['Location'])
+ self.assertEqual([f'Unfollowed bar/url.'],
+ get_flashed_messages())
+
+ inbox_args, inbox_kwargs = mock_post.call_args_list[1]
+ self.assertEqual(('http://bar/inbox',), inbox_args)
+ self.assert_equals(UNDO_FOLLOW, json_loads(inbox_kwargs['data']))
+
+ follower = Follower.get_by_id('https://bar/id snarfed.org')
+ self.assertEqual('inactive', follower.status)
+
+ activities = Activity.query().fetch()
+ self.assert_entities_equal(
+ [Activity(id='http://localhost/user/snarfed.org/following__undo-2022-01-02T03:04:05-https://bar/id https://bar/id', domain=['snarfed.org'],
+ status='complete', protocol='activitypub', direction='out',
+ source_as2=json_dumps(UNDO_FOLLOW))],
+ activities,
+ ignore=['created', 'updated'])
+
+ 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': 'https://bar/id',
+ })
+ with self.client:
+ resp = self.client.get(f'/unfollow/callback?code=my_code&state={state}')
+ self.assertEqual(400, resp.status_code)