From b701dfbf57f433c99eff1a30acc2d2c395fee8e7 Mon Sep 17 00:00:00 2001 From: Ryan Barrett Date: Wed, 21 Dec 2022 14:49:22 -0800 Subject: [PATCH] unfollow via 410, in progress --- tests/test_webmention.py | 32 ++++++++++++++++++++++++++++ webmention.py | 45 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/tests/test_webmention.py b/tests/test_webmention.py index 9cf4e85..34988d7 100644 --- a/tests/test_webmention.py +++ b/tests/test_webmention.py @@ -888,6 +888,38 @@ class WebmentionTest(testutil.TestCase): self.assert_equals('a', followers[0].src) self.assert_equals('https://foo.com/about-me', followers[0].dest) + def test_activitypub_unfollow(self, mock_get, mock_post): + Activity.get_or_create('http://a/follow', 'https://foo.com/about-me', + status='complete') + Follower.get_or_create('https://foo.com/about-me', 'a') + + self.follow.status_code = 410 + mock_get.side_effect = [self.follow, self.actor] + mock_post.return_value = requests_response('abc xyz') + + got = self.client.post('/webmention', data={ + 'source': 'http://a/follow', + 'target': 'https://fed.brid.gy/', + }) + self.assertEqual(200, got.status_code) + + args, kwargs = mock_post.call_args + self.assert_equals(('https://foo.com/inbox',), args) + self.assert_equals({ + '@context': 'https://www.w3.org/ns/activitystreams', + 'type': 'Undo', + 'object': self.follow_as2, + }, json_loads(kwargs['data'])) + + activity = Activity.get_by_id('http://a/follow#deleted http://followee/') + self.assertEqual(['a'], activity.domain) + self.assertEqual('out', activity.direction) + self.assertEqual('activitypub', activity.protocol) + self.assertEqual('complete', activity.status) + self.assertIsNone(activity.source_mf2) + + self.assertEqual(0, Follower.query().count()) + def test_activitypub_error_fragment_missing(self, mock_get, mock_post): mock_get.side_effect = [self.follow_fragment] diff --git a/webmention.py b/webmention.py index 25efe7d..f6c1fcf 100644 --- a/webmention.py +++ b/webmention.py @@ -50,13 +50,24 @@ class Webmention(View): # fetch source page source = flask_util.get_required_param('source') - logger.info(f'webmention from {util.domain_from_link(source, minimize=False)}') + self.source_domain = util.domain_from_link(source, minimize=False) + logger.info(f'webmention from {self.source_domain}') try: - source_resp = util.requests_get(source, gateway=True) + source_resp = util.requests_get(source) + source_resp.raise_for_status() except ValueError as e: error(f'Bad source URL: {source}: {e}') + except BaseException as e: + status, body = util.interpret_http_exception(e) + if status == '410': + if self.try_activitypub_unfollow(source_resp.url or source): + return 'OK' + else: + error(f"410 HTTP response is only supported for unfollow; couldn't find a matching follow post to unfollow") + else: + error(f'Could not fetch source URL {source}: {e}', status=502) + self.source_url = source_resp.url or source - self.source_domain = urllib.parse.urlparse(self.source_url).netloc.split(':')[0] fragment = urllib.parse.urlparse(self.source_url).fragment self.source_mf2 = util.parse_mf2(source_resp, id=fragment) @@ -129,6 +140,7 @@ class Webmention(View): items[0].get('properties') ).get('content') + orig_content = content(json_loads(activity.source_mf2)) new_content = content(self.source_mf2) if orig_content and new_content and orig_content == new_content: @@ -166,6 +178,33 @@ class Webmention(View): else: return str(error) + def try_activitypub_unfollow(self, follow_url): + """Attempts ActivityPub Undo Follow. + + Args: + follow_url: str + + Returns: boolean, whether we successfully unfollowed + """ + logging.info(f'Attempting to undo follow {follow_url}') + for activity in Activity.query().filter( + Activity.key > Key('Response', follow_url + ' '), + Activity.key < Key('Response', follow_url + chr(ord(' ') + 1))): + if activity.status == 'complete': + logging.info(f'Found follow activity {activity.key}') + follower = Follower.get_by_id( + Follower._id(activity.target(), self.source_domain)) + if follower: + logging.info(f'Deactivating existing Follower {follower.key}') + follower.status = 'inactive' + follower.put() + STATE: construct Activity here, send AP Undo + { + '@context': 'https://www.w3.org/ns/activitystreams', + 'type': 'Undo', + }) + return True + def _activitypub_targets(self): """ Returns: list of (Activity, string inbox URL)