add follow UI to user/[domain]/following

for #351
pull/356/head
Ryan Barrett 2023-01-07 09:34:55 -08:00
rodzic 6e000d348d
commit c50f0e0106
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 6BE31FDF4776E9D4
8 zmienionych plików z 252 dodań i 54 usunięć

Wyświetl plik

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

164
follow.py
Wyświetl plik

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

Wyświetl plik

@ -0,0 +1 @@
https://fed.brid.gy/

Wyświetl plik

@ -97,6 +97,7 @@ def user(domain):
follow_url=request.values.get('url'),
logs=logs,
util=util,
**request.args,
**locals(),
)

Wyświetl plik

@ -6,7 +6,20 @@
{% include "user_addresses.html" %}
<div class="row">Following</div>
<div class="row big">Following</div>
<div class="row">
<form method="post" action="/follow">
<p>
<label for="follower-address">Add a follower (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>
<input name="me" type="hidden" value="https://{{ domain }}"></input>
<button type="submit" class="btn btn-default">Follow</button>
</p>
</form>
</div>
{% include "_followers.html" %}

Wyświetl plik

@ -49,7 +49,7 @@
</div>
<div class="row">
<form method="post" action="/follow">
<form method="post" action="/remote-follow">
<p>
<label for="follow-address">Enter your fediverse address to follow:</label>
<input id="follow-address" name="address" type="text" required
@ -61,8 +61,6 @@
</form>
</div>
<!-- <div class="row">Recent activity</div> -->
{% include "activities.html" %}
{% endblock %}

Wyświetl plik

@ -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('<html>not json</html>')
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)

Wyświetl plik

@ -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,