#351

pretty complete, except for activity text snippet rendering
pull/359/head
Ryan Barrett 2023-01-09 19:01:48 -08:00
rodzic e6308543a9
commit d2eda25375
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 6BE31FDF4776E9D4
5 zmienionych plików z 197 dodań i 51 usunięć

Wyświetl plik

@ -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'])

Wyświetl plik

@ -3,13 +3,21 @@
{% for f in followers %}
<li class="row">
<a class="follower col-xs-12 col-sm-6 col-lg-6" href="{{ f.url }}">
<a class="follower col-xs-10 col-sm-6 col-lg-6" href="{{ f.url }}">
{% if f.picture %}
<img class="profile u-photo" src="{{ f.picture }}" width="48px">
{% endif %}
{{ f.name or '' }}
{{ f.name or '' }}
{{ f.handle }}
</a>
{% if page_name == 'following' %}
<form method="post" action="/unfollow/start" class="col-xs-2 col-sm-1 col-lg-1">
<input type="hidden" name="me" value="{{ domain }}" />
<input type="hidden" name="key" value="{{ f.key.id() }}" />
<input type="submit" title="Unfollow (requires IndieAuth)" value="✖"
class="btn delete-website" />
</form>
{% endif %}
</li>
{% else %}
None yet. Check back soon!

Wyświetl plik

@ -8,6 +8,8 @@
<div class="row big">Followers</div>
{% include "_followers.html" %}
{% with page_name="followers" %}
{% include "_followers.html" %}
{% endwith %}
{% endblock %}

Wyświetl plik

@ -11,7 +11,7 @@
<div class="row">
<form method="post" action="/follow/start">
<p>
<label for="follower-address">Follow another account (requires <a href="https://indieauth.net/">IndieAuth</a>):</label>
<label for="follower-address">Follow someone (requires <a href="https://indieauth.net/">IndieAuth</a>):</label>
<input id="follower-address" name="address" type="text" required
placeholder="@user@domain.social" alt="fediverse address"
value="{{ address or '' }}"></input>
@ -21,6 +21,8 @@
</form>
</div>
{% include "_followers.html" %}
{% with page_name="following" %}
{% include "_followers.html" %}
{% endwith %}
{% endblock %}

Wyświetl plik

@ -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 <a href="https://bar/url">{input}</a>.'], get_flashed_messages())
self.assertEqual([f'Followed <a href="https://bar/url">{input}</a>.'],
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 <a href="https://bar/url">bar/url</a>.'],
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)